From 9d2c83d9041f0d11b118b8f2ead4eca846635fcd Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 7 May 2026 10:58:11 -0700 Subject: [PATCH] refactor(web): redesign run stages tab as a row list with detail panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the per-event SystemBlock / AssistantBlock / ToolBlock / CommandBlock layout with a flat list of three-column rows (label pill, truncated summary, elapsed time from run start) and a slide-out detail panel that opens on row click. Tool names are humanized (read_file → "Read", shell → "Bash", etc.). The vertical column separator now extends to the actual window bottom via an absolutely positioned line that bleeds 1.5rem past its flex parent's bottom edge into the layout's bottom padding, sidestepping a calc(100% + 3rem) approach that wasn't resolving reliably on a flex-1 ancestor. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/fabro-web/app/routes/run-stages.test.ts | 34 +- apps/fabro-web/app/routes/run-stages.tsx | 727 ++++++++----------- 2 files changed, 294 insertions(+), 467 deletions(-) diff --git a/apps/fabro-web/app/routes/run-stages.test.ts b/apps/fabro-web/app/routes/run-stages.test.ts index 52f10ed05..339a8e559 100644 --- a/apps/fabro-web/app/routes/run-stages.test.ts +++ b/apps/fabro-web/app/routes/run-stages.test.ts @@ -1,21 +1,7 @@ import { describe, expect, test } from "bun:test"; import type { EventEnvelope } from "@qltysh/fabro-api-client"; -import { eventsToActivity, isSafeMarkdownHref } from "./run-stages"; - -describe("isSafeMarkdownHref", () => { - test("rejects protocol-relative URLs", () => { - expect(isSafeMarkdownHref("//attacker.example/pixel.png")).toBe(false); - }); - - test("accepts root-relative, hash, http, https, and mailto URLs", () => { - expect(isSafeMarkdownHref("/runs/run-1")).toBe(true); - expect(isSafeMarkdownHref("#section-1")).toBe(true); - expect(isSafeMarkdownHref("https://fabro.sh")).toBe(true); - expect(isSafeMarkdownHref("http://localhost:3000")).toBe(true); - expect(isSafeMarkdownHref("mailto:test@example.com")).toBe(true); - }); -}); +import { eventsToActivity } from "./run-stages"; function envelope(seq: number, partial: Partial): EventEnvelope { return { @@ -59,14 +45,14 @@ describe("eventsToActivity", () => { const firstVisit = eventsToActivity(events, "verify@1"); expect(firstVisit).toEqual([ - { kind: "system", content: "first visit prompt" }, - { kind: "assistant", content: "first visit reply" }, + { kind: "system", ts: "2026-04-09T12:00:00Z", content: "first visit prompt" }, + { kind: "assistant", ts: "2026-04-09T12:00:00Z", content: "first visit reply" }, ]); const secondVisit = eventsToActivity(events, "verify@2"); expect(secondVisit).toEqual([ - { kind: "system", content: "second visit prompt" }, - { kind: "assistant", content: "second visit reply" }, + { kind: "system", ts: "2026-04-09T12:00:00Z", content: "second visit prompt" }, + { kind: "assistant", ts: "2026-04-09T12:00:00Z", content: "second visit reply" }, ]); }); @@ -94,11 +80,7 @@ describe("eventsToActivity", () => { expect(turns).toHaveLength(1); expect(turns[0]).toMatchObject({ kind: "command", - stageId: "fmt", script: "cargo fmt", - language: "shell", - stdout: "ok", - exitCode: 0, running: false, }); }); @@ -130,7 +112,6 @@ describe("eventsToActivity", () => { const turn = turns[0]; expect(turn.kind).toBe("command"); if (turn.kind === "command") { - expect(turn.stageId).toBe("verify@2"); expect(turn.script).toBe("echo hi"); expect(turn.running).toBe(false); } @@ -163,11 +144,8 @@ describe("eventsToActivity", () => { expect(turns).toHaveLength(1); expect(turns[0].kind).toBe("tool"); if (turns[0].kind === "tool") { - expect(turns[0].tools).toHaveLength(1); - expect(turns[0].tools[0]).toMatchObject({ - id: "call-1", + expect(turns[0]).toMatchObject({ toolName: "read_file", - result: "[redis]", isError: false, }); } diff --git a/apps/fabro-web/app/routes/run-stages.tsx b/apps/fabro-web/app/routes/run-stages.tsx index d6ef9514d..e5cd4e54c 100644 --- a/apps/fabro-web/app/routes/run-stages.tsx +++ b/apps/fabro-web/app/routes/run-stages.tsx @@ -1,69 +1,23 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useParams } from "react-router"; -import { Marked } from "marked"; +import { XMarkIcon } from "@heroicons/react/24/outline"; -const SAFE_HTTP_URL_RE = /^https?:\/\//i; -const SAFE_MAILTO_URL_RE = /^mailto:/i; - -export function isSafeMarkdownHref(href: string): boolean { - return ( - SAFE_HTTP_URL_RE.test(href) || - SAFE_MAILTO_URL_RE.test(href) || - href.startsWith("#") || - (href.startsWith("/") && !href.startsWith("//")) - ); -} - -const markedSafe = new Marked(); -markedSafe.use({ - async: false, - walkTokens(token) { - if ( - (token.type === "link" || token.type === "image") && - typeof token.href === "string" && - !isSafeMarkdownHref(token.href) - ) { - token.href = ""; - } - }, - renderer: { - html() { - return ""; - }, - }, -}); -import { CommandLineIcon, ChatBubbleLeftIcon, PlayIcon } from "@heroicons/react/24/outline"; -import { ToolBlock } from "../components/tool-use"; -import type { ToolUse } from "../components/tool-use"; import { StageSidebar } from "../components/stage-sidebar"; import type { Stage } from "../components/stage-sidebar"; import { EmptyState } from "../components/state"; -import { CopyButton } from "../components/ui"; -import { fetchRunCommandLog, useRunStageEvents, useRunStages } from "../lib/queries"; +import { useRun, useRunStageEvents, useRunStages } from "../lib/queries"; import { STAGE_ACTIVITY_EVENT_TYPES, type StageActivityEventType } from "../lib/run-events"; import { mapRunStagesToSidebarStages } from "../lib/stage-sidebar"; import { getNumber, getString, type UnknownRecord } from "../lib/unknown"; -import { - CommandOutputStream, - CommandTermination, - type EventEnvelope, -} from "@qltysh/fabro-api-client"; +import type { EventEnvelope } from "@qltysh/fabro-api-client"; export const handle = { wide: true, fullHeight: true }; type TurnType = - | { kind: "system"; content: string } - | { kind: "assistant"; content: string } - | { kind: "tool"; tools: ToolUse[] } - | { kind: "command"; stageId: string; script: string; language: string; stdout?: string; stderr?: string; exitCode?: number | null; durationMs?: number; termination?: CommandTermination; running: boolean }; - -function readTermination(props: UnknownRecord): CommandTermination { - const v = props.termination; - if (v === CommandTermination.EXITED || v === CommandTermination.TIMED_OUT || v === CommandTermination.CANCELLED) { - return v; - } - return CommandTermination.EXITED; -} + | { kind: "system"; ts: string; content: string } + | { kind: "assistant"; ts: string; content: string } + | { kind: "tool"; ts: string; toolName: string; input: string; result: string; isError: boolean } + | { kind: "command"; ts: string; script: string; running: boolean; exitCode: number | null; durationMs: number }; const STAGE_ACTIVITY_EVENT_SET = new Set(STAGE_ACTIVITY_EVENT_TYPES); @@ -77,12 +31,21 @@ function activityEventStageId(event: EventEnvelope): string | undefined { return getString(event.properties ?? {}, "node_id"); } +interface PendingTool { + ts: string; + toolName: string; + input: string; +} + +interface PendingCommand { + ts: string; + script: string; +} + export function eventsToActivity(events: EventEnvelope[], stageId: string): TurnType[] { const turns: TurnType[] = []; - // Collect tool pairs: started → completed - const pendingTools = new Map(); - // Track pending command for pairing started → completed - let pendingCommand: { stageId: string; script: string; language: string } | undefined; + const pendingTools = new Map(); + let pendingCommand: PendingCommand | undefined; for (const e of events) { const eventName = e.event; @@ -93,24 +56,22 @@ export function eventsToActivity(events: EventEnvelope[], stageId: string): Turn ) { continue; } - // Exhaustive switch over StageActivityEventType: adding a new variant to - // STAGE_ACTIVITY_EVENT_TYPES forces a TS error here until the case is - // handled, keeping the SWR invalidation set and the reducer in sync. const eventType = eventName as StageActivityEventType; - const props = e.properties ?? {}; + const props: UnknownRecord = e.properties ?? {}; switch (eventType) { case "stage.prompt": - turns.push({ kind: "system", content: getString(props, "text") ?? e.text ?? "" }); + turns.push({ kind: "system", ts: e.ts, content: getString(props, "text") ?? e.text ?? "" }); break; case "agent.message": { const msg = getString(props, "text") ?? e.text ?? ""; - if (msg) turns.push({ kind: "assistant", content: msg }); + if (msg) turns.push({ kind: "assistant", ts: e.ts, content: msg }); break; } case "agent.tool.started": { const callId = getString(props, "tool_call_id") ?? e.tool_call_id ?? ""; const args = props.arguments ?? e.arguments; pendingTools.set(callId, { + ts: e.ts, toolName: getString(props, "tool_name") ?? e.tool_name ?? "", input: typeof args === "string" ? args : JSON.stringify(args ?? ""), }); @@ -119,39 +80,34 @@ export function eventsToActivity(events: EventEnvelope[], stageId: string): Turn case "agent.tool.completed": { const callId = getString(props, "tool_call_id") ?? e.tool_call_id ?? ""; const started = pendingTools.get(callId); + pendingTools.delete(callId); const output = props.output ?? e.output ?? ""; - const result = typeof output === "string" ? output : JSON.stringify(output); - const tool: ToolUse = { - id: callId, + const result = typeof output === "string" ? output : JSON.stringify(output, null, 2); + turns.push({ + kind: "tool", + ts: started?.ts ?? e.ts, toolName: started?.toolName ?? getString(props, "tool_name") ?? e.tool_name ?? "", input: started?.input ?? "", result, isError: (props.is_error ?? e.is_error) === true, - }; - pendingTools.delete(callId); - turns.push({ kind: "tool", tools: [tool] }); + }); break; } case "command.started": { pendingCommand = { - stageId, + ts: e.ts, script: getString(props, "script") ?? "", - language: getString(props, "language") ?? "shell", }; break; } case "command.completed": { turns.push({ kind: "command", - stageId: pendingCommand?.stageId ?? stageId, + ts: pendingCommand?.ts ?? e.ts, script: pendingCommand?.script ?? "", - language: pendingCommand?.language ?? "shell", - stdout: getString(props, "stdout") ?? "", - stderr: getString(props, "stderr") ?? "", + running: false, exitCode: getNumber(props, "exit_code") ?? null, durationMs: getNumber(props, "duration_ms") ?? 0, - termination: readTermination(props), - running: false, }); pendingCommand = undefined; break; @@ -161,392 +117,268 @@ export function eventsToActivity(events: EventEnvelope[], stageId: string): Turn } } - // If command.started was seen but no command.completed, it's still running if (pendingCommand) { turns.push({ kind: "command", - stageId: pendingCommand.stageId, + ts: pendingCommand.ts, script: pendingCommand.script, - language: pendingCommand.language, running: true, + exitCode: null, + durationMs: 0, }); } return turns; } -function Markdown({ content }: { content: string }) { - const html = useMemo(() => markedSafe.parse(content, { async: false }) as string, [content]); - return ( -
- ); +function turnLabel(turn: TurnType): string { + switch (turn.kind) { + case "system": + return "System"; + case "assistant": + return "Agent"; + case "tool": + return "Tool"; + case "command": + return "Command"; + } } -function SystemBlock({ content }: { content: string }) { - return ( -
-
- - System prompt -
- -
-
- -
- ); +function turnTone(turn: TurnType): string { + if (turn.kind === "tool" && turn.isError) { + return "bg-coral/15 text-coral"; + } + switch (turn.kind) { + case "system": + return "bg-amber/15 text-amber"; + case "assistant": + return "bg-teal-500/15 text-teal-500"; + case "tool": + case "command": + return "bg-mint/15 text-mint"; + } } -function AssistantBlock({ content }: { content: string }) { - return ( -
-
- - Assistant -
- -
-
- -
- ); +const SUMMARY_MAX_CHARS = 80; + +function oneLine(text: string): string { + const collapsed = text.replace(/\s+/g, " ").trim(); + if (collapsed.length <= SUMMARY_MAX_CHARS) return collapsed; + return `${collapsed.slice(0, SUMMARY_MAX_CHARS - 1)}…`; } -function StatusPill({ - tone, - children, +const TOOL_NAME_DISPLAY: Record = { + read_file: "Read", + write_file: "Write", + edit_file: "Edit", + shell: "Bash", + grep: "Grep", + glob: "Glob", + read_many_files: "Read Many", + list_dir: "List Dir", + web_search: "Web Search", + web_fetch: "Web Fetch", +}; + +export function humanizeToolName(raw: string): string { + if (!raw) return "tool"; + if (TOOL_NAME_DISPLAY[raw]) return TOOL_NAME_DISPLAY[raw]; + // MCP tools are namespaced like `mcp____`; display the trailing segment. + const lastSegment = raw.split("__").pop() ?? raw; + return lastSegment + .split(/[_-]+/) + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); +} + +export function turnSummary(turn: TurnType): string { + switch (turn.kind) { + case "system": + case "assistant": + return oneLine(turn.content); + case "tool": + return humanizeToolName(turn.toolName); + case "command": + return oneLine(turn.script) || (turn.running ? "running…" : ""); + } +} + +export function formatElapsed(eventTs: string, runStart: string | undefined): string { + if (!runStart) return ""; + const startMs = Date.parse(runStart); + const eventMs = Date.parse(eventTs); + if (Number.isNaN(startMs) || Number.isNaN(eventMs)) return ""; + const delta = Math.max(0, Math.floor((eventMs - startMs) / 1000)); + const hours = Math.floor(delta / 3600); + const minutes = Math.floor((delta % 3600) / 60); + const seconds = delta % 60; + return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`; +} + +function EventRow({ + turn, + runStart, + selected, + onSelect, }: { - tone: "running" | "failed" | "success" | "neutral"; - children: React.ReactNode; + turn: TurnType; + runStart: string | undefined; + selected: boolean; + onSelect: () => void; }) { - const toneClass = { - running: "bg-teal-500/15 text-teal-500", - failed: "bg-coral/15 text-coral", - success: "bg-mint/15 text-mint", - neutral: "bg-overlay text-fg-3", - }[tone]; return ( - - {children} - + ); } -const COLLAPSE_AFTER_LINES = 20; -const LOG_POLL_INTERVAL_MS = 1000; -const LOG_FETCH_LIMIT_BYTES = 65_536; -const LOG_MEMORY_CAP_BYTES = 5 * 1024 * 1024; - -function StreamLabel({ label }: { label: string }) { - return ( -
- {label} -
- ); -} - -interface CommandLogState { - text: string; - eof: boolean; - loading: boolean; - error: boolean; - truncated: boolean; - casRef: string | null; - liveStreaming: boolean; - totalBytes: number; -} - -function decodeBase64Bytes(value: string): Uint8Array { - if (!value) return new Uint8Array(); - const binary = atob(value); - const bytes = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i += 1) { - bytes[i] = binary.charCodeAt(i); - } - return bytes; -} - -function trimTextToBytes(text: string, maxBytes: number) { - // Each UTF-16 code unit encodes to at most 3 bytes in UTF-8 (4-byte encodings - // come from surrogate pairs counted as 2 units). Skip the full encode when - // the upper bound is already under the cap. - if (text.length * 3 <= maxBytes) { - return { text, truncated: false }; - } - const encoded = new TextEncoder().encode(text); - if (encoded.byteLength <= maxBytes) { - return { text, truncated: false }; - } - const start = encoded.byteLength - maxBytes; - const trimmed = new TextDecoder().decode(encoded.slice(start)); - return { text: trimmed.replace(/^\uFFFD/, ""), truncated: true }; -} - -function useCommandLog( - runId: string | undefined, - stageId: string | undefined, - stream: CommandOutputStream, - running: boolean, -): CommandLogState { - const [state, setState] = useState({ - text: "", - eof: false, - loading: true, - error: false, - truncated: false, - casRef: null, - liveStreaming: false, - totalBytes: 0, - }); - const offsetRef = useRef(0); - const finalPollDoneRef = useRef(false); - const decoderRef = useRef(new TextDecoder()); - - useEffect(() => { - offsetRef.current = 0; - finalPollDoneRef.current = false; - decoderRef.current = new TextDecoder(); - setState({ - text: "", - eof: false, - loading: true, - error: false, - truncated: false, - casRef: null, - liveStreaming: false, - totalBytes: 0, - }); - }, [runId, stageId, stream]); - - useEffect(() => { - if (!runId || !stageId) return; - let cancelled = false; - let timer: ReturnType | undefined; - - async function poll() { - try { - const chunk = await fetchRunCommandLog( - runId, - stageId, - stream, - offsetRef.current, - LOG_FETCH_LIMIT_BYTES, - ); - if (cancelled) return; - - offsetRef.current = chunk.next_offset; - const bytes = decodeBase64Bytes(chunk.bytes_base64); - const decoded = decoderRef.current.decode(bytes, { stream: !chunk.eof }); - finalPollDoneRef.current = chunk.eof; - setState((current) => { - if ( - decoded.length === 0 && - current.eof === chunk.eof && - current.totalBytes === chunk.total_bytes && - current.casRef === chunk.cas_ref && - current.liveStreaming === chunk.live_streaming && - !current.loading && - !current.error - ) { - return current; - } - const next = trimTextToBytes(current.text + decoded, LOG_MEMORY_CAP_BYTES); - return { - text: next.text, - eof: chunk.eof, - loading: false, - error: false, - truncated: current.truncated || next.truncated, - casRef: chunk.cas_ref, - liveStreaming: chunk.live_streaming, - totalBytes: chunk.total_bytes, - }; - }); - } catch { - if (!cancelled) { - setState((current) => ({ ...current, loading: false, error: true })); - } - } - - if (!cancelled && (running || !finalPollDoneRef.current)) { - timer = setTimeout(poll, LOG_POLL_INTERVAL_MS); - } - } - - void poll(); - return () => { - cancelled = true; - if (timer) clearTimeout(timer); - }; - }, [runId, running, stageId, stream]); - - return state; -} - -function streamStatus(state: CommandLogState, hasContent: boolean): string { - if (state.error) return "Failed to load"; - if (state.loading) return "Waiting"; - if (hasContent) { - if (state.eof) return state.casRef ? "Stored" : "Complete"; - return state.liveStreaming ? "Streaming" : "Running"; - } - return state.eof ? "No output" : "Waiting"; -} - -function OutputStream({ +function DetailField({ label, - state, - tone = "normal", - forceExpanded = false, + children, + mono = false, }: { label: string; - state: CommandLogState; - tone?: "normal" | "error"; - forceExpanded?: boolean; + children: React.ReactNode; + mono?: boolean; }) { - const content = state.text; - const lines = content.split("\n"); - const isLong = lines.length > COLLAPSE_AFTER_LINES; - const [expanded, setExpanded] = useState(forceExpanded); - const scrollRef = useRef(null); - const followTailRef = useRef(true); - const visible = isLong && !expanded - ? lines.slice(-COLLAPSE_AFTER_LINES).join("\n") - : content; - const hiddenLines = isLong && !expanded ? lines.length - COLLAPSE_AFTER_LINES : 0; - const preClass = - tone === "error" - ? "whitespace-pre-wrap font-mono text-sm leading-relaxed text-coral sm:text-xs" - : "whitespace-pre-wrap font-mono text-sm leading-relaxed text-fg-3 sm:text-xs"; - const status = streamStatus(state, content.length > 0); - - useEffect(() => { - if (!forceExpanded) return; - setExpanded(true); - }, [forceExpanded]); - - useEffect(() => { - const el = scrollRef.current; - if (el && followTailRef.current) { - el.scrollTop = el.scrollHeight; - } - }, [visible]); - return (
-
- - {status} - {state.truncated ? ( - Last 5 MiB - ) : null} - +
+ {label}
- {isLong && !expanded ? ( - - ) : null} - {content.length === 0 ? ( -
- {state.error ? "Unable to fetch this stream." : "No bytes received yet."} -
- ) : ( -
 {
-            const el = event.currentTarget;
-            followTailRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 24;
-          }}
-          className={`${preClass} max-h-96 overflow-auto`}
-        >
-          {visible}
-        
+
+ {children} +
+
+ ); +} + +function CodeBlock({ children }: { children: string }) { + return ( +
+      {children || empty}
+    
+ ); +} + +function EventDetails({ turn, runStart }: { turn: TurnType; runStart: string | undefined }) { + const elapsed = formatElapsed(turn.ts, runStart); + const absolute = (() => { + const ms = Date.parse(turn.ts); + if (Number.isNaN(ms)) return turn.ts; + return new Date(ms).toLocaleString(); + })(); + + return ( +
+ + {elapsed ? `${elapsed} · ${absolute}` : absolute} + + + {(turn.kind === "system" || turn.kind === "assistant") && ( + + {turn.content} + + )} + + {turn.kind === "tool" && ( + <> + + {humanizeToolName(turn.toolName)}{" "} + ({turn.toolName}) + + + {turn.input} + + + {turn.result} + + + )} + + {turn.kind === "command" && ( + <> + + {turn.running + ? "Running…" + : `exit ${turn.exitCode ?? "?"}${ + turn.durationMs + ? ` · ${ + turn.durationMs < 1000 + ? `${turn.durationMs}ms` + : `${(turn.durationMs / 1000).toFixed(1)}s` + }` + : "" + }`} + + + {turn.script} + + )}
); } -function CommandBlock({ - runId, +function EventDetailsPanel({ turn, + runStart, + onClose, }: { - runId: string | undefined; - turn: Extract; + turn: TurnType | null; + runStart: string | undefined; + onClose: () => void; }) { - const failed = !turn.running && (turn.termination !== CommandTermination.EXITED || turn.exitCode !== 0); - const stdout = useCommandLog(runId, turn.stageId, CommandOutputStream.STDOUT, turn.running); - const stderr = useCommandLog(runId, turn.stageId, CommandOutputStream.STDERR, turn.running); - const borderColor = turn.running ? "border-teal-500/20" : failed ? "border-coral/15" : "border-mint/15"; - const bgColor = turn.running ? "bg-teal-500/5" : failed ? "bg-coral/5" : "bg-mint/5"; + useEffect(() => { + if (!turn) return; + function handleKey(event: KeyboardEvent) { + if (event.key === "Escape") onClose(); + } + window.addEventListener("keydown", handleKey); + return () => window.removeEventListener("keydown", handleKey); + }, [turn, onClose]); return ( -
- {/* Header */} -
- - - {turn.language === "python" ? "Python" : "Shell"} - -
- {turn.running ? ( - Running… - ) : turn.termination === CommandTermination.TIMED_OUT ? ( - Timed out - ) : turn.termination === CommandTermination.CANCELLED ? ( - Cancelled - ) : ( - <> - - exit {turn.exitCode ?? "?"} - - {turn.durationMs != null && ( - - {turn.durationMs < 1000 - ? `${turn.durationMs}ms` - : `${(turn.durationMs / 1000).toFixed(1)}s`} - - )} - - )} - {turn.script ? ( -
- -
- ) : null} -
+
+
+

+ {turn ? `${turnLabel(turn)} event` : ""} +

+
- - {/* Script */} - {turn.script && ( -
-
{turn.script}
-
- )} - -
-
- -
-
- 0} - /> -
+
+ {turn ? : null}
); @@ -554,6 +386,7 @@ function CommandBlock({ export default function RunStages() { const { id, stageId } = useParams(); + const runQuery = useRun(id); const stagesQuery = useRunStages(id); const stages = useMemo( () => mapRunStagesToSidebarStages(stagesQuery.data), @@ -571,6 +404,12 @@ export default function RunStages() { [stageEventsQuery.data, selectedStageId], ); + const [openIndex, setOpenIndex] = useState(null); + useEffect(() => { + setOpenIndex(null); + }, [selectedStageId]); + const openTurn = openIndex != null ? turns[openIndex] ?? null : null; + if (!id || !stages.length) { return (
@@ -582,28 +421,38 @@ export default function RunStages() { ); } + const runStart = runQuery.data?.created_at; + return ( -
+
-