web/app/components/pipeline-run-detail.tsxblame
View source
da0f6511"use client";
da0f6512
5bcd5db3import { useEffect, useRef, useState, useCallback } from "react";
da0f6514import { canopy, type PipelineRun, type PipelineStep, type StepLog } from "@/lib/api";
da0f6515import { useAuth } from "@/lib/auth";
da0f6516import { useParams } from "next/navigation";
087adca7import { Badge } from "@/app/components/ui/badge";
087adca8import { TimeAgo } from "@/app/components/time-ago";
087adca9import { ElapsedTime } from "@/app/components/elapsed-time";
fe3b50910import { LogViewer } from "@/app/components/log-viewer";
5bcd5db11import { useCanopyEvents, type CanopyEvent } from "@/lib/use-canopy-events";
da0f65112
da0f65113const statusIcon: Record<string, { color: string; label: string }> = {
da0f65114 pending: { color: "var(--text-faint)", label: "Pending" },
da0f65115 running: { color: "var(--status-merged-text)", label: "Running" },
da0f65116 passed: { color: "var(--status-open-text)", label: "Passed" },
da0f65117 failed: { color: "var(--status-closed-text)", label: "Failed" },
da0f65118 skipped: { color: "var(--text-faint)", label: "Skipped" },
da0f65119 cancelled: { color: "var(--text-faint)", label: "Cancelled" },
da0f65120};
da0f65121
da0f65122function formatDuration(ms: number | null): string {
da0f65123 if (!ms) return "-";
da0f65124 if (ms < 1000) return "<1s";
da0f65125 const s = Math.floor(ms / 1000);
da0f65126 if (s < 60) return `${s}s`;
da0f65127 const m = Math.floor(s / 60);
da0f65128 const rem = s % 60;
da0f65129 return `${m}m ${rem}s`;
da0f65130}
da0f65131
087adca32function formatTrigger(trigger: PipelineRun["trigger_type"]): string {
0fdef1433 return trigger
0fdef1434 .split("_")
0fdef1435 .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
0fdef1436 .join(" ");
0fdef1437}
0fdef1438
0fdef1439function getActiveStepIndex(steps: PipelineStep[]): number | null {
0fdef1440 const running = steps.find((step) => step.status === "running");
0fdef1441 if (running) return running.step_index;
0fdef1442 const pending = steps.find((step) => step.status === "pending");
0fdef1443 if (pending) return pending.step_index;
0fdef1444 return null;
087adca45}
087adca46
fe3b50947interface PipelineRunDetailProps {
fe3b50948 owner?: string;
fe3b50949 repo?: string;
fe3b50950 runId?: string;
fe3b50951 embedded?: boolean;
fe3b50952 renderLayout?: (stepsColumn: React.ReactNode, logsColumn: React.ReactNode, actions?: React.ReactNode) => React.ReactNode;
fe3b50953}
fe3b50954
fe3b50955export function PipelineRunDetail(props: PipelineRunDetailProps) {
fe3b50956 const routeParams = useParams<{ owner: string; repo: string; runId: string }>();
fe3b50957 const owner = props.owner ?? routeParams.owner;
fe3b50958 const repo = props.repo ?? routeParams.repo;
fe3b50959 const runId = props.runId ?? routeParams.runId;
fe3b50960 const embedded = props.embedded ?? false;
da0f65161 const { user } = useAuth();
0fdef1462 const runNumericId = Number.parseInt(runId, 10);
da0f65163
da0f65164 const [run, setRun] = useState<PipelineRun | null>(null);
da0f65165 const [steps, setSteps] = useState<PipelineStep[]>([]);
da0f65166 const [expandedSteps, setExpandedSteps] = useState<Set<number>>(new Set());
da0f65167 const [stepLogs, setStepLogs] = useState<Record<number, StepLog[]>>({});
da0f65168 const [loading, setLoading] = useState(true);
da0f65169 const [cancelling, setCancelling] = useState(false);
0fdef1470 const [groveOrigin, setGroveOrigin] = useState("");
0fdef1471 const stepButtonRefs = useRef<Record<number, HTMLButtonElement | null>>({});
0fdef1472 const stickToBottomRef = useRef<Record<number, boolean>>({});
0fdef1473 const followedStepRef = useRef<number | null>(null);
da0f65174
da0f65175 useEffect(() => {
0fdef1476 setLoading(true);
0fdef1477 setRun(null);
0fdef1478 setSteps([]);
0fdef1479 setExpandedSteps(new Set());
0fdef1480 setStepLogs({});
0fdef1481 followedStepRef.current = null;
0fdef1482 void loadRun();
da0f65183 }, [owner, repo, runId]);
da0f65184
0fdef1485 useEffect(() => {
0fdef1486 const { protocol, host } = window.location;
0fdef1487 const groveHost = host.startsWith("canopy.") ? host.slice("canopy.".length) : host;
0fdef1488 setGroveOrigin(`${protocol}//${groveHost}`);
0fdef1489 }, []);
0fdef1490
5bcd5db91 // Live updates via SSE
5bcd5db92 const isActive = run?.status === "running" || run?.status === "pending";
5bcd5db93 useCanopyEvents({
5bcd5db94 scope: "run",
5bcd5db95 runId: runNumericId,
5bcd5db96 enabled: isActive || !run,
5bcd5db97 onEvent: useCallback((event: CanopyEvent) => {
5bcd5db98 switch (event.type) {
5bcd5db99 case "run:started":
5bcd5db100 case "run:completed":
5bcd5db101 case "run:cancelled":
5bcd5db102 if (event.run) setRun(event.run as unknown as PipelineRun);
5bcd5db103 break;
5bcd5db104 case "step:started":
5bcd5db105 case "step:completed":
5bcd5db106 case "step:skipped":
5bcd5db107 if (event.step) {
5bcd5db108 setSteps((prev) =>
5bcd5db109 prev.map((s) =>
5bcd5db110 s.step_index === event.stepIndex
5bcd5db111 ? { ...s, ...(event.step as unknown as PipelineStep) }
5bcd5db112 : s
5bcd5db113 )
5bcd5db114 );
5bcd5db115 }
5bcd5db116 break;
5bcd5db117 case "log:append":
5bcd5db118 if (event.log && event.stepIndex != null) {
5bcd5db119 setStepLogs((prev) => ({
5bcd5db120 ...prev,
5bcd5db121 [event.stepIndex!]: [
5bcd5db122 ...(prev[event.stepIndex!] ?? []),
5bcd5db123 event.log as unknown as StepLog,
5bcd5db124 ],
5bcd5db125 }));
5bcd5db126 }
5bcd5db127 break;
5bcd5db128 }
5bcd5db129 }, []),
5bcd5db130 });
0fdef14131
fe3b509132 // Auto-follow currently active step, or auto-select on completed builds.
0fdef14133 useEffect(() => {
fe3b509134 if (steps.length === 0) return;
0fdef14135 const activeStepIndex = getActiveStepIndex(steps);
0fdef14136
fe3b509137 // For finished builds, auto-select the failed step or last step.
fe3b509138 const targetIndex = activeStepIndex
fe3b509139 ?? steps.find((s) => s.status === "failed")?.step_index
fe3b509140 ?? steps[steps.length - 1]?.step_index
fe3b509141 ?? null;
fe3b509142 if (targetIndex === null) return;
fe3b509143
fe3b509144 const isNewStep = followedStepRef.current !== targetIndex;
fe3b509145 if (activeStepIndex !== null && isNewStep) {
fe3b509146 stickToBottomRef.current[targetIndex] = true;
fe3b509147 }
0fdef14148 setExpandedSteps((prev) => {
fe3b509149 if (prev.size === 1 && prev.has(targetIndex)) return prev;
fe3b509150 return new Set([targetIndex]);
0fdef14151 });
fe3b509152 void loadStepLogs(targetIndex);
0fdef14153
fe3b509154 if (isNewStep) {
fe3b509155 followedStepRef.current = targetIndex;
0fdef14156 requestAnimationFrame(() => {
fe3b509157 stepButtonRefs.current[targetIndex]?.scrollIntoView({
0fdef14158 block: "nearest",
0fdef14159 });
0fdef14160 });
0fdef14161 }
0fdef14162 }, [steps, run?.status]);
0fdef14163
5bcd5db164 // SSE handles live log streaming — no polling needed.
da0f651165
da0f651166 async function loadRun() {
da0f651167 try {
0fdef14168 const data = await canopy.getRun(owner, repo, runNumericId);
da0f651169 setRun(data.run);
da0f651170 setSteps(data.steps);
da0f651171 } catch {}
da0f651172 setLoading(false);
da0f651173 }
da0f651174
0fdef14175 async function loadStepLogs(stepIndex: number, force = false) {
0fdef14176 if (!force && stepLogs[stepIndex]) return;
0fdef14177 try {
0fdef14178 const data = await canopy.getStepLogs(owner, repo, runNumericId, stepIndex);
0fdef14179 setStepLogs((prev) => {
0fdef14180 const existing = prev[stepIndex];
0fdef14181 const next = data.logs;
0fdef14182 if (
0fdef14183 existing &&
0fdef14184 existing.length === next.length &&
0fdef14185 existing[existing.length - 1]?.created_at ===
0fdef14186 next[next.length - 1]?.created_at &&
0fdef14187 existing[existing.length - 1]?.content === next[next.length - 1]?.content
0fdef14188 ) {
0fdef14189 return prev;
0fdef14190 }
0fdef14191 return { ...prev, [stepIndex]: next };
0fdef14192 });
0fdef14193 } catch {}
0fdef14194 }
0fdef14195
da0f651196 async function toggleStep(stepIndex: number) {
fe3b509197 if (expandedSteps.has(stepIndex)) return;
0fdef14198
0fdef14199 stickToBottomRef.current[stepIndex] = true;
fe3b509200 setExpandedSteps(new Set([stepIndex]));
0fdef14201 await loadStepLogs(stepIndex, true);
da0f651202 }
da0f651203
da0f651204 async function handleCancel() {
da0f651205 setCancelling(true);
da0f651206 try {
0fdef14207 const data = await canopy.cancelRun(owner, repo, runNumericId);
da0f651208 setRun(data.run);
da0f651209 } catch {}
da0f651210 setCancelling(false);
da0f651211 }
da0f651212
8ed080e213 // Update page title on status change (favicon is handled by CanopyNav)
ad0b63b214 useEffect(() => {
fe3b509215 if (!run || embedded) return;
86450dc216 document.title = `Build #${run.id} · ${repo}`;
8ed080e217 }, [run?.id, run?.status, owner, repo, embedded]);
ad0b63b218
da0f651219 if (loading) {
fe3b509220 if (embedded) {
fe3b509221 const skeletonSteps = (
fe3b509222 <div className="flex-1 min-w-0 overflow-y-auto" style={{ borderRight: "1px solid var(--border-subtle)" }}>
fe3b509223 <div className="px-3 py-1.5 text-xs" style={{ color: "var(--text-faint)", borderBottom: "1px solid var(--divide)" }}>
fe3b509224 Steps
fe3b509225 </div>
fe3b509226 {Array.from({ length: 3 }).map((_, i) => (
fe3b509227 <div key={i} className="w-full text-left px-3 py-2 text-sm" style={{ borderBottom: "1px solid var(--divide)" }}>
fe3b509228 <div className="flex items-center gap-2">
fe3b509229 <div className="skeleton" style={{ height: "1.25rem", width: "1.25rem", borderRadius: "2px" }} />
fe3b509230 <div className="skeleton" style={{ height: "0.85rem", flex: 1 }} />
fe3b509231 </div>
fe3b509232 </div>
fe3b509233 ))}
fe3b509234 </div>
fe3b509235 );
fe3b509236 const skeletonLogs = (
fe3b509237 <div className="flex-1 flex items-center justify-center">
fe3b509238 <p className="text-sm" style={{ color: "var(--text-faint)" }}>Loading...</p>
fe3b509239 </div>
fe3b509240 );
fe3b509241 if (props.renderLayout) {
fe3b509242 return <>{props.renderLayout(skeletonSteps, skeletonLogs)}</>;
fe3b509243 }
fe3b509244 return (
fe3b509245 <div className="flex" style={{ height: "100%" }} aria-busy="true" aria-live="polite">
fe3b509246 {skeletonSteps}
fe3b509247 {skeletonLogs}
fe3b509248 </div>
fe3b509249 );
fe3b509250 }
da0f651251 return (
087adca252 <div className="max-w-3xl mx-auto px-4 py-6" aria-busy="true" aria-live="polite">
087adca253 <div
087adca254 className="p-4 mb-4"
087adca255 style={{
087adca256 backgroundColor: "var(--bg-card)",
087adca257 border: "1px solid var(--border-subtle)",
087adca258 }}
087adca259 >
087adca260 <div className="skeleton mb-3" style={{ height: "1.5rem", width: "14rem" }} />
087adca261 <div className="skeleton mb-2" style={{ height: "0.9rem", width: "78%" }} />
087adca262
087adca263 <div className="grid grid-cols-2 md:grid-cols-4 gap-3 text-xs mt-3">
087adca264 {Array.from({ length: 4 }).map((_, i) => (
087adca265 <div key={i}>
087adca266 <div className="skeleton mb-1" style={{ height: "0.55rem", width: "2.8rem" }} />
087adca267 <div className="skeleton" style={{ height: "0.75rem", width: i === 1 ? "4.5rem" : "5.5rem" }} />
087adca268 </div>
087adca269 ))}
087adca270 </div>
087adca271 </div>
087adca272
087adca273 <div style={{ border: "1px solid var(--border-subtle)" }}>
087adca274 <div
087adca275 className="px-3 py-2 text-xs"
087adca276 style={{
087adca277 color: "var(--text-muted)",
087adca278 borderBottom: "1px solid var(--divide)",
087adca279 backgroundColor: "var(--bg-card)",
087adca280 }}
087adca281 >
087adca282 Build Steps
087adca283 </div>
087adca284 {Array.from({ length: 4 }).map((_, i) => (
087adca285 <div
087adca286 key={i}
087adca287 style={{ borderTop: i > 0 ? "1px solid var(--divide)" : undefined }}
087adca288 >
087adca289 <div className="px-3 py-2.5">
087adca290 <div className="flex items-center gap-2">
087adca291 <div className="skeleton" style={{ height: "1.2rem", width: "4.6rem" }} />
087adca292 <div className="skeleton" style={{ height: "0.95rem", flex: 1 }} />
087adca293 <div className="skeleton" style={{ height: "0.8rem", width: "3rem" }} />
087adca294 </div>
087adca295 </div>
087adca296 </div>
087adca297 ))}
087adca298 </div>
da0f651299 </div>
da0f651300 );
da0f651301 }
da0f651302
da0f651303 if (!run) {
da0f651304 return (
fe3b509305 <div className={embedded ? "px-3 py-4" : "max-w-3xl mx-auto px-4 py-6"}>
da0f651306 <p className="text-sm" style={{ color: "var(--text-faint)" }}>
087adca307 Build not found.
da0f651308 </p>
da0f651309 </div>
da0f651310 );
da0f651311 }
0fdef14312 const activeStepIndex = getActiveStepIndex(steps);
0fdef14313 const commitPath = run.commit_id ? `/${owner}/${repo}/commit/${run.commit_id}` : null;
0fdef14314 const commitHref = commitPath ? `${groveOrigin}${commitPath}` : null;
da0f651315
fe3b509316 if (embedded) {
fe3b509317 const expandedStep = steps.find((s) => expandedSteps.has(s.step_index));
fe3b509318 const expandedLogs = expandedStep ? stepLogs[expandedStep.step_index] : undefined;
fe3b509319
fe3b509320 const stepsColumn = (
fe3b509321 <div
fe3b509322 className="flex-1 min-w-0 overflow-y-auto"
fe3b509323 style={{
fe3b509324 borderRight: "1px solid var(--border-subtle)",
fe3b509325 }}
fe3b509326 >
fe3b509327 <div className="px-3 py-1.5 text-xs" style={{ color: "var(--text-faint)", borderBottom: "1px solid var(--divide)" }}>
fe3b509328 Steps
fe3b509329 </div>
fe3b509330 {steps.map((step, i) => {
fe3b509331 const ss = statusIcon[step.status] ?? statusIcon.pending;
fe3b509332 const expanded = expandedSteps.has(step.step_index);
fe3b509333 const isActive = activeStepIndex === step.step_index;
fe3b509334
fe3b509335 return (
fe3b509336 <button
fe3b509337 key={step.id}
fe3b509338 ref={(node) => {
fe3b509339 stepButtonRefs.current[step.step_index] = node;
fe3b509340 }}
fe3b509341 onClick={() => toggleStep(step.step_index)}
fe3b509342 className="w-full text-left px-3 py-2 hover-row text-sm"
fe3b509343 style={{
fe3b509344 cursor: "pointer",
fe3b509345 backgroundColor: expanded
fe3b509346 ? "var(--bg-inset)"
fe3b509347 : isActive
fe3b509348 ? "var(--accent-subtle)"
fe3b509349 : undefined,
fe3b509350 borderBottom: "1px solid var(--divide)",
fe3b509351 fontWeight: expanded ? 600 : 400,
fe3b509352 color: expanded ? "var(--text-primary)" : "var(--text-muted)",
fe3b509353 }}
fe3b509354 >
fe3b509355 <div className="flex items-center gap-2">
fe3b509356 <Badge variant={step.status} compact>
fe3b509357 {ss.label.charAt(0)}
fe3b509358 </Badge>
fe3b509359 <span className="truncate" style={{ flex: 1 }}>
fe3b509360 {step.name}
fe3b509361 </span>
fe3b509362 <span className="text-xs" style={{ color: "var(--text-faint)", flexShrink: 0 }}>
fe3b509363 {step.status === "running"
fe3b509364 ? <ElapsedTime since={step.started_at ?? run.started_at ?? run.created_at} />
fe3b509365 : formatDuration(step.duration_ms)}
fe3b509366 </span>
fe3b509367 </div>
fe3b509368 </button>
fe3b509369 );
fe3b509370 })}
fe3b509371 </div>
fe3b509372 );
fe3b509373
fe3b509374 const logsColumn = (
fe3b509375 <div className="flex-1 min-w-0 flex flex-col" style={{ height: "100%" }}>
fe3b509376 {expandedStep ? (
fe3b509377 expandedLogs ? (
fe3b509378 expandedLogs.length > 0 ? (
fe3b509379 <LogViewer
fe3b509380 logs={expandedLogs}
fe3b509381 stickToBottom={stickToBottomRef.current[expandedStep.step_index] !== false}
fe3b509382 onStickToBottomChange={(value) => {
fe3b509383 stickToBottomRef.current[expandedStep.step_index] = value;
fe3b509384 }}
fe3b509385 style={{ height: "100%" }}
fe3b509386 />
fe3b509387 ) : (
fe3b509388 <div className="flex-1 flex items-center justify-center">
fe3b509389 <p className="text-sm" style={{ color: "var(--text-faint)" }}>
fe3b509390 No output.
fe3b509391 </p>
fe3b509392 </div>
fe3b509393 )
fe3b509394 ) : (
fe3b509395 <div className="flex-1 flex items-center justify-center">
fe3b509396 <p className="text-sm" style={{ color: "var(--text-faint)" }}>
fe3b509397 Loading logs...
fe3b509398 </p>
fe3b509399 </div>
fe3b509400 )
fe3b509401 ) : (
fe3b509402 <div className="flex-1 flex items-center justify-center">
fe3b509403 <p className="text-sm" style={{ color: "var(--text-faint)" }}>
fe3b509404 Select a step to view logs
fe3b509405 </p>
fe3b509406 </div>
fe3b509407 )}
fe3b509408 </div>
fe3b509409 );
fe3b509410
fe3b509411 const cancelAction = run.status === "running" && user ? (
fe3b509412 <button
fe3b509413 onClick={(e) => { e.stopPropagation(); handleCancel(); }}
fe3b509414 disabled={cancelling}
fe3b509415 className="px-1.5 py-0.5"
fe3b509416 style={{
fe3b509417 color: "var(--status-closed-text)",
fe3b509418 border: "1px solid var(--status-closed-border)",
fe3b509419 fontSize: "0.7rem",
fe3b509420 background: "none",
fe3b509421 cursor: "pointer",
fe3b509422 }}
fe3b509423 >
fe3b509424 {cancelling ? "..." : "Cancel"}
fe3b509425 </button>
fe3b509426 ) : null;
fe3b509427
fe3b509428 if (props.renderLayout) {
fe3b509429 return <>{props.renderLayout(stepsColumn, logsColumn, cancelAction)}</>;
fe3b509430 }
fe3b509431
fe3b509432 return (
fe3b509433 <div className="flex" style={{ height: "100%" }}>
fe3b509434 {stepsColumn}
fe3b509435 {logsColumn}
fe3b509436 </div>
fe3b509437 );
fe3b509438 }
fe3b509439
da0f651440 return (
da0f651441 <div className="max-w-3xl mx-auto px-4 py-6">
da0f651442 <div
da0f651443 className="p-4 mb-4"
da0f651444 style={{
da0f651445 backgroundColor: "var(--bg-card)",
da0f651446 border: "1px solid var(--border-subtle)",
da0f651447 }}
da0f651448 >
087adca449 <div className="flex items-start justify-between gap-3 mb-2">
087adca450 <div className="min-w-0">
087adca451 <div className="flex items-center gap-2">
087adca452 <span className="text-lg truncate">{run.pipeline_name}</span>
087adca453 </div>
da0f651454 </div>
da0f651455 {run.status === "running" && user && (
da0f651456 <button
da0f651457 onClick={handleCancel}
da0f651458 disabled={cancelling}
da0f651459 className="text-xs px-2 py-1"
da0f651460 style={{
da0f651461 color: "var(--status-closed-text)",
da0f651462 border: "1px solid var(--status-closed-border)",
da0f651463 }}
da0f651464 >
087adca465 {cancelling ? "Cancelling..." : "Cancel Build"}
da0f651466 </button>
da0f651467 )}
da0f651468 </div>
da0f651469 {run.commit_message && (
da0f651470 <div className="text-sm mt-1" style={{ color: "var(--text-secondary)" }}>
da0f651471 {run.commit_message}
da0f651472 </div>
da0f651473 )}
087adca474
087adca475 <div className="grid grid-cols-2 md:grid-cols-4 gap-3 text-xs mt-3">
087adca476 <div>
087adca477 <div style={{ color: "var(--text-faint)" }}>Trigger</div>
087adca478 <div style={{ color: "var(--text-secondary)" }}>
087adca479 {formatTrigger(run.trigger_type)}
087adca480 {run.trigger_ref ? ` · ${run.trigger_ref}` : ""}
087adca481 </div>
087adca482 </div>
087adca483 <div>
087adca484 <div style={{ color: "var(--text-faint)" }}>Commit</div>
087adca485 <div className="font-mono" style={{ color: "var(--text-secondary)" }}>
0fdef14486 {run.commit_id ? (
0fdef14487 <a
0fdef14488 href={commitHref ?? commitPath ?? "#"}
0fdef14489 className="hover:underline"
0fdef14490 target="_blank"
0fdef14491 rel="noopener noreferrer"
0fdef14492 >
0fdef14493 {run.commit_id.substring(0, 8)}
0fdef14494 </a>
0fdef14495 ) : (
0fdef14496 "-"
0fdef14497 )}
087adca498 </div>
087adca499 </div>
087adca500 <div>
087adca501 <div style={{ color: "var(--text-faint)" }}>Duration</div>
087adca502 <div style={{ color: "var(--text-secondary)" }}>
087adca503 {run.status === "running" && run.started_at
087adca504 ? <ElapsedTime since={run.started_at} />
087adca505 : formatDuration(run.duration_ms)}
087adca506 </div>
087adca507 </div>
087adca508 <div>
087adca509 <div style={{ color: "var(--text-faint)" }}>Started</div>
087adca510 <div style={{ color: "var(--text-secondary)" }}>
087adca511 <TimeAgo date={run.started_at ?? run.created_at} />
087adca512 </div>
087adca513 </div>
da0f651514 </div>
da0f651515 </div>
da0f651516
da0f651517 <div style={{ border: "1px solid var(--border-subtle)" }}>
087adca518 <div
087adca519 className="px-3 py-2 text-xs"
087adca520 style={{
087adca521 color: "var(--text-muted)",
087adca522 borderBottom: "1px solid var(--divide)",
087adca523 backgroundColor: "var(--bg-card)",
087adca524 }}
087adca525 >
087adca526 Build Steps ({steps.length})
087adca527 </div>
da0f651528 {steps.map((step, i) => {
da0f651529 const ss = statusIcon[step.status] ?? statusIcon.pending;
da0f651530 const expanded = expandedSteps.has(step.step_index);
da0f651531 const logs = stepLogs[step.step_index];
0fdef14532 const isActive = activeStepIndex === step.step_index;
da0f651533
da0f651534 return (
da0f651535 <div
da0f651536 key={step.id}
da0f651537 style={{
da0f651538 borderTop: i > 0 ? "1px solid var(--divide)" : undefined,
da0f651539 }}
da0f651540 >
da0f651541 <button
0fdef14542 ref={(node) => {
0fdef14543 stepButtonRefs.current[step.step_index] = node;
0fdef14544 }}
da0f651545 onClick={() => toggleStep(step.step_index)}
087adca546 className="w-full text-left px-3 py-2.5 hover-row text-sm"
0fdef14547 style={{
0fdef14548 cursor: "pointer",
0fdef14549 backgroundColor: isActive ? "var(--accent-subtle)" : undefined,
0fdef14550 }}
0fdef14551 aria-expanded={expanded}
da0f651552 >
087adca553 <div className="flex items-center gap-2">
087adca554 <Badge variant={step.status} style={{ minWidth: "4.6rem", textAlign: "center" }}>
087adca555 {ss.label}
087adca556 </Badge>
087adca557 <span style={{ color: "var(--text-primary)", minWidth: 0, flex: 1 }}>
087adca558 {step.name}
087adca559 </span>
087adca560 <span
087adca561 className="text-xs"
087adca562 style={{ color: "var(--text-faint)", width: "4.2rem", textAlign: "right" }}
087adca563 >
087adca564 {step.status === "running"
087adca565 ? <ElapsedTime since={step.started_at ?? run.started_at ?? run.created_at} />
087adca566 : formatDuration(step.duration_ms)}
087adca567 </span>
087adca568 <span
087adca569 className="text-xs"
087adca570 style={{ color: "var(--text-faint)", width: "1rem", textAlign: "right" }}
087adca571 >
087adca572 {expanded ? "▾" : "▸"}
087adca573 </span>
087adca574 </div>
da0f651575 </button>
da0f651576
da0f651577 {expanded && (
da0f651578 <div
da0f651579 className="px-3 pb-3"
da0f651580 style={{ backgroundColor: "var(--bg-inset)" }}
da0f651581 >
da0f651582 {logs ? (
da0f651583 logs.length > 0 ? (
0fdef14584 <div
da0f651585 style={{
087adca586 border: "1px solid var(--border-subtle)",
0fdef14587 backgroundColor: "var(--bg-page)",
da0f651588 }}
da0f651589 >
fe3b509590 <LogViewer
fe3b509591 logs={logs}
fe3b509592 stickToBottom={stickToBottomRef.current[step.step_index] !== false}
fe3b509593 onStickToBottomChange={(value) => {
fe3b509594 stickToBottomRef.current[step.step_index] = value;
0fdef14595 }}
fe3b509596 style={{ maxHeight: 380 }}
fe3b509597 />
0fdef14598 </div>
da0f651599 ) : (
da0f651600 <p
da0f651601 className="text-xs py-2"
da0f651602 style={{ color: "var(--text-faint)" }}
da0f651603 >
da0f651604 No output.
da0f651605 </p>
da0f651606 )
da0f651607 ) : (
da0f651608 <p
da0f651609 className="text-xs py-2"
da0f651610 style={{ color: "var(--text-faint)" }}
da0f651611 >
da0f651612 Loading logs...
da0f651613 </p>
da0f651614 )}
da0f651615 </div>
da0f651616 )}
da0f651617 </div>
da0f651618 );
da0f651619 })}
da0f651620 </div>
da0f651621 </div>
da0f651622 );
da0f651623}