feat(run-details-panel): enhance run details display and error handling

- Improved the layout and readability of the run details panel by restructuring sections and adding collapsible error views.
- Introduced a new `RunErrorSection` component to present run-level errors more clearly, allowing users to toggle raw error details.
- Updated the handling of run outputs, step results, and artifacts for better user experience.
- Refactored duration calculation in `RunRow` to utilize a dedicated `formatDuration` function for consistency.
- Added a new `RunStepResult` interface to improve type safety and clarity in handling step results.
This commit is contained in:
DESKTOP-RTLN3BA\$punk 2026-05-29 13:43:58 -07:00
parent cb2e33e083
commit fade9d1b9d
5 changed files with 268 additions and 44 deletions

View file

@ -1,8 +1,24 @@
"use client";
import { AlertCircle, FileOutput, GitCommitHorizontal, Package, Settings2 } from "lucide-react";
import {
AlertCircle,
ChevronDown,
FileOutput,
GitCommitHorizontal,
Package,
Settings2,
} from "lucide-react";
import { useState } from "react";
import { JsonView } from "@/components/json-view";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import type { RunStepResult } from "@/contracts/types/automation.types";
import { useAutomationRun } from "@/hooks/use-automation-runs";
import { cn } from "@/lib/utils";
import { RunStepResultCard } from "./run-step-result-card";
interface RunDetailsPanelProps {
automationId: number;
@ -13,10 +29,11 @@ interface RunDetailsPanelProps {
* Expanded view of a single run. Fetches lazily the parent only renders
* this once the row is opened, so the list view stays cheap.
*
* We surface the four most actionable sections (error first when present,
* then output, step results, artifacts, inputs). The full
* ``definition_snapshot`` is omitted because it usually mirrors the live
* definition surfacing it would dominate the panel without informing
* We surface the run outcome readably: a run-level error first (when
* present), then per-step cards that render the agent's markdown
* ``final_message`` directly, and finally the structural artifacts/inputs.
* The full ``definition_snapshot`` is omitted because it usually mirrors the
* live definition surfacing it would dominate the panel without informing
* what the user is trying to learn ("did this work? what did it do?").
*/
export function RunDetailsPanel({ automationId, runId }: RunDetailsPanelProps) {
@ -24,7 +41,7 @@ export function RunDetailsPanel({ automationId, runId }: RunDetailsPanelProps) {
if (isLoading) {
return (
<div className="space-y-3 p-4 bg-muted/20 border-t border-border/60">
<div className="flex flex-col gap-3 border-t border-border/60 bg-muted/20 p-4">
<Skeleton className="h-3 w-32" />
<Skeleton className="h-24 w-full" />
</div>
@ -33,53 +50,104 @@ export function RunDetailsPanel({ automationId, runId }: RunDetailsPanelProps) {
if (error || !run) {
return (
<div className="p-4 bg-muted/20 border-t border-border/60 text-xs text-muted-foreground">
<div className="border-t border-border/60 bg-muted/20 p-4 text-xs text-muted-foreground">
Couldn't load run details{error?.message ? `: ${error.message}` : "."}
</div>
);
}
const hasError = run.error && Object.keys(run.error).length > 0;
const runError = run.error && Object.keys(run.error).length > 0 ? run.error : null;
const hasOutput = run.output && Object.keys(run.output).length > 0;
const hasInputs = Object.keys(run.inputs ?? {}).length > 0;
const steps = run.step_results as RunStepResult[];
const hasDiagnostics = run.artifacts.length > 0 || hasInputs;
return (
<div className="space-y-4 p-4 bg-muted/20 border-t border-border/60">
{hasError && (
<Section icon={AlertCircle} label="Error" tone="destructive">
<JsonBlock value={run.error} />
</Section>
)}
<div className="flex flex-col gap-4 border-t border-border/60 bg-muted/20 p-4">
{runError ? <RunErrorSection error={runError} /> : null}
{hasOutput && (
{hasOutput ? (
<Section icon={FileOutput} label="Output">
<JsonBlock value={run.output} />
</Section>
)}
) : null}
<Section icon={GitCommitHorizontal} label={`Step results · ${run.step_results.length}`}>
{run.step_results.length === 0 ? (
<Section icon={GitCommitHorizontal} label={`Step results · ${steps.length}`}>
{steps.length === 0 ? (
<p className="text-xs text-muted-foreground">No steps recorded.</p>
) : (
<JsonBlock value={run.step_results} />
<div className="flex flex-col gap-2">
{steps.map((step, index) => (
<RunStepResultCard key={step.step_id ?? index} step={step} />
))}
</div>
)}
</Section>
{run.artifacts.length > 0 && (
{hasDiagnostics ? <Separator className="bg-border/60" /> : null}
{run.artifacts.length > 0 ? (
<Section icon={Package} label={`Artifacts · ${run.artifacts.length}`}>
<JsonBlock value={run.artifacts} />
</Section>
)}
) : null}
{hasInputs && (
{hasInputs ? (
<Section icon={Settings2} label="Resolved inputs">
<JsonBlock value={run.inputs} />
</Section>
)}
) : null}
</div>
);
}
/**
* Run-level error: a readable destructive alert when a message is present,
* with the full structured error available behind a raw toggle.
*/
function RunErrorSection({ error }: { error: Record<string, unknown> }) {
const [rawOpen, setRawOpen] = useState(false);
const message = typeof error.message === "string" ? error.message : null;
const type = typeof error.type === "string" ? error.type : "Run failed";
return (
<Section icon={AlertCircle} label="Error" tone="destructive">
{message ? (
<Alert variant="destructive">
<AlertCircle aria-hidden />
<AlertTitle>{type}</AlertTitle>
<AlertDescription className="wrap-break-word">{message}</AlertDescription>
</Alert>
) : null}
<Collapsible open={rawOpen} onOpenChange={setRawOpen} className="mt-2">
<CollapsibleTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 w-fit px-2 text-xs text-muted-foreground"
aria-expanded={rawOpen}
>
<ChevronDown
className={cn(
"transition-transform motion-reduce:transition-none",
rawOpen && "rotate-180"
)}
aria-hidden
/>
{rawOpen ? "Hide raw" : "View raw"}
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
<ScrollArea className="mt-2 max-h-64 rounded-md bg-muted/40 px-3 py-2">
<JsonView src={error} collapsed={1} />
</ScrollArea>
</CollapsibleContent>
</Collapsible>
</Section>
);
}
function Section({
icon: Icon,
label,
@ -92,15 +160,14 @@ function Section({
children: React.ReactNode;
}) {
return (
<div className="space-y-1.5">
<div className="flex flex-col gap-1.5">
<div
className={
tone === "destructive"
? "flex items-center gap-1.5 text-[11px] font-medium text-destructive uppercase tracking-wider"
: "flex items-center gap-1.5 text-[11px] font-medium text-muted-foreground uppercase tracking-wider"
}
className={cn(
"flex items-center gap-1.5 text-[11px] font-medium uppercase tracking-wider",
tone === "destructive" ? "text-destructive" : "text-muted-foreground"
)}
>
<Icon className="h-3 w-3" aria-hidden />
<Icon className="size-3" aria-hidden />
{label}
</div>
{children}
@ -110,8 +177,8 @@ function Section({
function JsonBlock({ value }: { value: unknown }) {
return (
<div className="rounded-md bg-muted/40 px-3 py-2 max-h-64 overflow-auto">
<ScrollArea className="max-h-64 rounded-md bg-muted/40 px-3 py-2">
<JsonView src={value} collapsed={1} />
</div>
</ScrollArea>
);
}

View file

@ -2,6 +2,7 @@
import { ChevronDown, ChevronRight, Hand } from "lucide-react";
import { useState } from "react";
import type { RunSummary } from "@/contracts/types/automation.types";
import { formatDuration } from "@/lib/automations/run-duration";
import { formatRelativeDate } from "@/lib/format-date";
import { RunDetailsPanel } from "./run-details-panel";
import { RunStatusBadge } from "./run-status-badge";
@ -18,7 +19,7 @@ interface RunRowProps {
*/
export function RunRow({ run, automationId }: RunRowProps) {
const [open, setOpen] = useState(false);
const duration = computeDuration(run.started_at, run.finished_at);
const duration = formatDuration(run.started_at, run.finished_at);
const startedLabel = run.started_at
? formatRelativeDate(run.started_at)
: formatRelativeDate(run.created_at);
@ -62,14 +63,3 @@ function TriggerSource({ triggerId }: { triggerId: number | null }) {
}
return <span>via trigger #{triggerId}</span>;
}
function computeDuration(started: string | null | undefined, finished: string | null | undefined) {
if (!started || !finished) return null;
const ms = new Date(finished).getTime() - new Date(started).getTime();
if (!Number.isFinite(ms) || ms < 0) return null;
if (ms < 1000) return `${ms}ms`;
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
const minutes = Math.floor(ms / 60_000);
const seconds = Math.floor((ms % 60_000) / 1000);
return `${minutes}m ${seconds}s`;
}

View file

@ -0,0 +1,123 @@
"use client";
import { CheckCircle2, ChevronDown, MinusCircle, XCircle } from "lucide-react";
import { memo, useState } from "react";
import { JsonView } from "@/components/json-view";
import { MarkdownViewer } from "@/components/markdown-viewer";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { ScrollArea } from "@/components/ui/scroll-area";
import type { RunStepResult } from "@/contracts/types/automation.types";
import { formatDuration } from "@/lib/automations/run-duration";
import { cn } from "@/lib/utils";
type BadgeVariant = React.ComponentProps<typeof Badge>["variant"];
const STATUS_BADGE: Record<
string,
{ label: string; variant: BadgeVariant; icon: typeof CheckCircle2 }
> = {
succeeded: { label: "Succeeded", variant: "outline", icon: CheckCircle2 },
failed: { label: "Failed", variant: "destructive", icon: XCircle },
skipped: { label: "Skipped", variant: "secondary", icon: MinusCircle },
};
function StepStatusBadge({ status }: { status: string }) {
const meta = STATUS_BADGE[status] ?? {
label: status,
variant: "outline" as const,
icon: MinusCircle,
};
const Icon = meta.icon;
return (
<Badge variant={meta.variant} className="shrink-0">
<Icon aria-hidden />
{meta.label}
</Badge>
);
}
/**
* One step from a run's ``step_results``. Surfaces the agent's markdown
* ``final_message`` first-class (rendered, not raw), shows step errors as a
* readable alert, and keeps the full structured payload behind a "View raw"
* collapsible escape hatch.
*/
export const RunStepResultCard = memo(function RunStepResultCard({
step,
}: {
step: RunStepResult;
}) {
const [rawOpen, setRawOpen] = useState(false);
const duration = formatDuration(step.started_at, step.finished_at);
const attempts = step.attempts ?? 0;
const finalMessage =
typeof step.result?.final_message === "string" ? step.result.final_message : null;
const errorMessage = step.error?.message;
const hasMeta = Boolean(duration) || attempts > 1;
return (
<Card className="border-border/60 shadow-none">
<CardHeader className="gap-2 space-y-0 p-3">
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2">
<span className="truncate font-mono text-xs font-medium">{step.action}</span>
<span className="truncate text-xs text-muted-foreground">{step.step_id}</span>
</div>
<StepStatusBadge status={step.status} />
</div>
{hasMeta ? (
<div className="flex items-center gap-3 text-[11px] text-muted-foreground tabular-nums">
{duration ? <span>{duration}</span> : null}
{attempts > 1 ? <span>{attempts} attempts</span> : null}
</div>
) : null}
</CardHeader>
<CardContent className="flex flex-col gap-3 p-3 pt-0">
{errorMessage ? (
<Alert variant="destructive">
<XCircle aria-hidden />
<AlertTitle>{step.error?.type ?? "Error"}</AlertTitle>
<AlertDescription className="wrap-break-word">{errorMessage}</AlertDescription>
</Alert>
) : null}
{finalMessage ? (
<div className="min-w-0 wrap-break-word rounded-md border border-border/60 bg-background px-3 py-2">
<MarkdownViewer content={finalMessage} />
</div>
) : null}
<Collapsible open={rawOpen} onOpenChange={setRawOpen}>
<CollapsibleTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 w-fit px-2 text-xs text-muted-foreground"
aria-expanded={rawOpen}
>
<ChevronDown
className={cn(
"transition-transform motion-reduce:transition-none",
rawOpen && "rotate-180"
)}
aria-hidden
/>
{rawOpen ? "Hide raw" : "View raw"}
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
<ScrollArea className="mt-2 max-h-64 rounded-md bg-muted/40 px-3 py-2">
<JsonView src={step} collapsed={1} />
</ScrollArea>
</CollapsibleContent>
</Collapsible>
</CardContent>
</Card>
);
});