import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useState, type ReactNode } from "react"; import { ArrowUpRight, GitPullRequest, Play, Shield, Terminal } from "lucide-react"; import type { components } from "../../api/schema"; import { apiClient, apiErrorMessage } from "../lib/api-client"; import { workspaceQueryKey } from "../hooks/useWorkspaceQuery"; import { formatTimeCompact } from "../lib/format-time"; import { useSessionScmSummary, type SessionPRSummary } from "../hooks/useSessionScmSummary"; import { prBrowserUrl, prStatusRows, sessionPRDisplaySummaries, type PRDisplayTone } from "../lib/pr-display"; import type { SessionActivityState, WorkspaceSession } from "../types/workspace"; import { canonicalTrackerIssueId, sortedPRs } from "../types/workspace"; import { BrowserPanelView } from "./BrowserPanel"; import type { BrowserViewModel } from "../hooks/useBrowserView"; import { Badge } from "./ui/badge"; import { Button } from "./ui/button"; import { cn } from "../lib/utils"; import { PRAttentionPanel, PRSummaryMeta } from "./PRSummaryDisplay"; type ProjectConfig = components["schemas"]["ProjectConfig"]; type PRReviewState = components["schemas"]["PRReviewState"]; type ReviewsResponse = components["schemas"]["ListReviewsResponse"]; type OpenReviewerTerminal = (target: { handleId: string; harness: string }) => void; export type InspectorView = "summary" | "reviews" | "browser"; const VIEWS: { id: InspectorView; label: string; icon: ReactNode }[] = [ { id: "summary", label: "Summary", icon: ( ), }, { id: "reviews", label: "Reviews", icon: ( ), }, { id: "browser", label: "Browser", icon: ( ), }, ]; const prStateTone: Record = { open: "border-success/40 bg-success/10 text-success", draft: "border-border bg-raised text-muted-foreground", merged: "border-accent/40 bg-accent-weak text-accent", closed: "border-error/40 bg-error/10 text-error", }; /** * Tabbed inspector rail beside the terminal (Summary · Reviews · Browser). */ export function SessionInspector({ session, onOpenReviewerTerminal, browserPoppedOut = false, isInspectorVisible = true, onToggleBrowserPopOut, browserView, view: viewProp, onViewChange, }: { session?: WorkspaceSession; onOpenReviewerTerminal?: OpenReviewerTerminal; browserPoppedOut?: boolean; isInspectorVisible?: boolean; onToggleBrowserPopOut?: (next: boolean) => void; browserView?: BrowserViewModel; /** Controlled active tab. Omit to let the inspector own its own selection. */ view?: InspectorView; onViewChange?: (view: InspectorView) => void; }) { const [internalView, setInternalView] = useState("summary"); const view = viewProp ?? internalView; const setView = (next: InspectorView) => { setInternalView(next); onViewChange?.(next); }; if (!session) { return ( ); } return ( ); } function Section({ action, children, className, title, }: { action?: ReactNode; children: ReactNode; className?: string; title: string; }) { return (
{title} {action ?? null}
{children}
); } function SummaryView({ session }: { session: WorkspaceSession }) { const query = useSessionScmSummary(session.id); const prSummaries = sessionPRDisplaySummaries(session, query.data); const prSectionTitle = prSummaries.length > 1 ? `Pull requests (${prSummaries.length})` : "Pull request"; const branchLabel = session.branch || `session/${session.id}`; const issueId = canonicalTrackerIssueId(session.issueId); return (
{prSummaries.length === 0 ? (

No pull request opened yet.

) : (
{prSummaries.map((pr) => ( ))}
)}
{issueId && }
); } function PRSummaryCard({ pr }: { pr: SessionPRSummary }) { return (
{pr.title ?
{pr.title}
: null}
); } function PRStatusStack({ className, pr }: { className?: string; pr: SessionPRSummary }) { return (
{prStatusRows(pr).map((row) => (
{row.label}{" "} {row.value}
))}
); } function inspectorStatusToneClass(tone: PRDisplayTone): string { switch (tone) { case "success": return "text-success"; case "warning": return "text-warning"; case "error": return "text-error"; case "neutral": return "text-muted-foreground"; case "passive": return "text-passive"; } } type TimelineTone = "now" | "good" | "warn" | "neutral"; function ActivityTimeline({ session }: { session: WorkspaceSession }) { const events: { tone: TimelineTone; node: ReactNode; ts: string | null }[] = []; events.push({ tone: "neutral", node: <>Created worktree & branch, ts: formatTimeCompact(session.createdAt ?? session.updatedAt), }); const prs = sortedPRs(session); for (const pr of prs.filter((pr) => pr.state === "draft")) { events.push({ tone: "neutral", node: ( <> Draft PR #{pr.number} ), ts: null, }); } for (const pr of prs.filter((pr) => pr.state !== "draft")) { events.push({ tone: "neutral", node: ( <> Opened PR #{pr.number} ), ts: null, }); } events.push({ tone: "now", node: ( {session.status === "no_signal" ? ( ) : null} {scmTimelineStates(session).map((state) => ( ))} ), ts: session.activity?.lastActivityAt ? formatTimeCompact(session.activity.lastActivityAt) : null, }); for (const pr of prs.filter((pr) => pr.state === "merged")) { events.push({ tone: "good", node: ( <> Merged PR #{pr.number} ), ts: null, }); } if (session.status === "merged") { events.push({ tone: "good", node: <>Done, ts: formatTimeCompact(session.updatedAt), }); } return (
{events.map((event, index) => (
))}
); } const ACTIVITY_PILL: Record = { active: { label: "Working", tone: "var(--orange)", breathe: true }, idle: { label: "Idle", tone: "var(--fg-muted)", breathe: false }, waiting_input: { label: "Input Needed", tone: "var(--amber)", breathe: false }, exited: { label: "Exited", tone: "var(--fg-muted)", breathe: false }, unknown: { label: "Activity Unavailable", tone: "var(--fg-muted)", breathe: false }, }; const ACTIVITY_WARNING_PILL: Record<"no_signal", { label: string; tone: string; breathe: boolean }> = { no_signal: { label: "No Signal", tone: "var(--fg-muted)", breathe: false }, }; type ScmTimelineState = "ci_failed" | "changes_requested" | "conflict"; const SCM_PILL: Record = { ci_failed: { label: "CI Failed", tone: "var(--red)", breathe: false }, changes_requested: { label: "Changes Requested", tone: "var(--amber)", breathe: false }, conflict: { label: "Conflict", tone: "var(--red)", breathe: false }, }; function InspectorActivityPill({ state }: { state: SessionActivityState }) { return ; } function InspectorScmPill({ state }: { state: ScmTimelineState }) { return ; } function TimelinePill({ label, tone, breathe }: { label: string; tone: string; breathe: boolean }) { return ( {label} ); } function scmTimelineStates(session: WorkspaceSession): ScmTimelineState[] { const states: ScmTimelineState[] = []; const seen = new Set(); const add = (state: ScmTimelineState) => { if (seen.has(state)) return; seen.add(state); states.push(state); }; if (session.status === "ci_failed") add("ci_failed"); if (session.status === "changes_requested") add("changes_requested"); for (const pr of session.prs) { if (pr.ci === "failing") add("ci_failed"); if (pr.review === "changes_requested") add("changes_requested"); if (pr.mergeability === "conflicting") add("conflict"); } return states; } function ReviewsView({ session, onOpenReviewerTerminal, }: { session: WorkspaceSession; onOpenReviewerTerminal?: OpenReviewerTerminal; }) { const hasPr = sortedPRs(session).length > 0; const queryClient = useQueryClient(); const [reviewNotice, setReviewNotice] = useState(null); const reviewsQuery = useQuery({ queryKey: ["session-reviews", session.id], enabled: hasPr, refetchInterval: (query) => { const data = query.state.data as ReviewsResponse | undefined; const reviews = data?.reviews ?? []; return reviews.some((review) => review.status === "running") ? 2500 : false; }, queryFn: async () => { const { data, error } = await apiClient.GET("/api/v1/sessions/{sessionId}/reviews", { params: { path: { sessionId: session.id } }, }); if (error) throw new Error(apiErrorMessage(error, "Unable to load reviews")); return data ?? ({ reviewerHandleId: "", reviews: [] } satisfies ReviewsResponse); }, }); const projectConfigQuery = useQuery({ queryKey: ["project-config", session.workspaceId], enabled: hasPr, queryFn: async () => { const { data, error } = await apiClient.GET("/api/v1/projects/{id}", { params: { path: { id: session.workspaceId } }, }); if (error) return undefined; return projectConfig(data?.project); }, }); const triggerReview = useMutation({ mutationFn: async () => { const { data, error, response } = await apiClient.POST("/api/v1/sessions/{sessionId}/reviews/trigger", { params: { path: { sessionId: session.id } }, }); if (error) throw new Error(apiErrorMessage(error, "Unable to start review")); return { data, reused: response?.status === 200 }; }, onMutate: () => { setReviewNotice(null); }, onSuccess: ({ data, reused }) => { void queryClient.invalidateQueries({ queryKey: ["session-reviews", session.id] }); void queryClient.invalidateQueries({ queryKey: workspaceQueryKey }); const started = data?.reviews?.find((review) => review.status === "running" && review.latestRun); if (reused || !started?.latestRun) { setReviewNotice("No needed reviews were started."); return; } if (data?.reviewerHandleId) { const harness = started.latestRun.harness || "reviewer"; onOpenReviewerTerminal?.({ handleId: data.reviewerHandleId, harness }); } }, }); const reviewStates = reviewsQuery.data?.reviews ?? []; return (
triggerReview.mutate()} reviewerHandleId={reviewsQuery.data?.reviewerHandleId ?? ""} reviewStates={reviewStates} notice={reviewNotice} session={session} />
); } function projectConfig(project: components["schemas"]["ProjectOrDegraded"] | undefined): ProjectConfig | undefined { if (!project || !("config" in project)) return undefined; return project.config; } function ReviewPanel({ session, config, reviewStates, reviewerHandleId, isLoading, isTriggering, error, notice, onTrigger, onOpenTerminal, }: { session: WorkspaceSession; config?: ProjectConfig; reviewStates: PRReviewState[]; reviewerHandleId: string; isLoading: boolean; isTriggering: boolean; error: unknown; notice: string | null; onTrigger: () => void; onOpenTerminal?: OpenReviewerTerminal; }) { if (sortedPRs(session).length === 0) { return

No pull request opened yet.

; } if (isLoading) { return

Loading reviews...

; } const latest = reviewStates.find((review) => review.latestRun)?.latestRun; const harness = latest?.harness || config?.reviewers?.[0]?.harness || "claude-code"; const terminalEnabled = Boolean(reviewerHandleId && onOpenTerminal); const aggregateVerdict = sessionReviewVerdict(reviewStates); const runAction = reviewSessionRunAction(reviewStates, isTriggering); const runDisabled = isTriggering || reviewStates.length === 0 || reviewStates.some((reviewState) => reviewState.status === "running") || reviewStates.every((reviewState) => reviewState.status === "ineligible"); return (
{error ?

{apiErrorMessage(error, "Review request failed")}

: null} {notice ?

{notice}

: null}
Pull requests {aggregateVerdict.label}
{reviewStates.length === 0 ?

No review state loaded yet.

: null} {reviewStates.map((reviewState) => ( ))}
); } function ReviewStateRow({ reviewState }: { reviewState: PRReviewState }) { const verdict = reviewVerdict(reviewState); const title = reviewState.title?.trim() || `PR #${reviewState.prNumber}`; return (
{verdict.label}
); } function sessionReviewVerdict(reviewStates: PRReviewState[]): { label: string; tone: "neutral" | "running" | "success" | "danger"; } { if (reviewStates.some((reviewState) => reviewState.status === "running")) { return { label: "Reviewing...", tone: "running" }; } if (reviewStates.some((reviewState) => reviewState.latestRun?.status === "failed")) { return { label: "Failed", tone: "danger" }; } if (reviewStates.some((reviewState) => reviewState.status === "changes_requested")) { return { label: "Changes requested", tone: "danger" }; } const eligibleReviews = reviewStates.filter((reviewState) => reviewState.status !== "ineligible"); if (eligibleReviews.length > 0 && eligibleReviews.every((reviewState) => reviewState.status === "up_to_date")) { return { label: "Approved", tone: "success" }; } return { label: "Not run", tone: "neutral" }; } function reviewVerdict(reviewState: PRReviewState): { label: string; tone: "neutral" | "running" | "success" | "danger"; } { if (reviewState.latestRun?.status === "failed") { return { label: "Failed", tone: "danger" }; } switch (reviewState.status) { case "running": return { label: "Reviewing...", tone: "running" }; case "up_to_date": return { label: "Approved", tone: "success" }; case "changes_requested": return { label: "Changes requested", tone: "danger" }; case "needs_review": case "ineligible": return { label: "Not run", tone: "neutral" }; } return { label: "Not run", tone: "neutral" }; } function reviewSessionRunAction(reviewStates: PRReviewState[], isTriggering: boolean): string { if (isTriggering || reviewStates.some((reviewState) => reviewState.status === "running")) { return "Reviewing..."; } if (reviewStates.some((reviewState) => reviewState.status === "changes_requested" || reviewState.latestRun)) { return "Re-run review"; } return "Run review"; } function BrowserView({ session, isActive, browserPoppedOut, onTogglePopOut, browserView, }: { session: WorkspaceSession; isActive: boolean; browserPoppedOut: boolean; onTogglePopOut?: (next: boolean) => void; browserView?: BrowserViewModel; }) { if (browserPoppedOut) { return (

Browser preview is in the center pane.

); } if (!browserView) { return null; } return ( onTogglePopOut?.(next)} poppedOut={false} session={session} /> ); } function Row({ k, v, mono }: { k: string; v: string; mono?: boolean }) { return (
{k}
{v}
); }