History

feat(execution-history): redesign UI to list-with-drawer pattern

Replace card list with table-row layout featuring inline status dots. Add status filter dropdown and text search for code/errors. Implement slide-in drawer (Dialog) for execution details. Split detail view into Properties and Logs tabs. Add Copy JSON action with copied feedback. Highlight durations >5000ms in red for visibility.

Saatvik Arya committed 7 months ago

Commit 29345981 parent e8209cb1 file · +501 −191Browse files

Unified · Split

plugins/execution-history/react/components.tsx+501−191
@@ -1,3 +1,4 @@
1+import { useState, useMemo, useCallback } from "react";
12 import type {
23 Execution,
34 ExecutionEnvelope,
@@ -7,56 +8,52 @@ import { · 3 unmodified lines
78 useExecutions,
89 } from "@executor/react";
910 import {
10− Alert,
1111 Badge,
1212 Button,
13− Card,
13+ cn,
14+ Dialog,
15+ DialogOverlay,
16+ DialogPopup,
17+ DialogPortal,
1418 DocumentPanel,
1519 EmptyState,
20+ IconClose,
21+ Input,
1622 LoadableBlock,
23+ Select,
1724 useExecutorPluginNavigation,
1825 useExecutorPluginRouteParams,
26+ useExecutorPluginSearch,
1927 } from "@executor/react/plugins";
2028
21−const formatTimestamp = (value: number | null): string => {
22− if (value === null) {
23− return "—";
24− }
29+// ── Helpers ──────────────────────────────────────────────────────────────
30+
31+type ExecutionStatus = Execution["status"];
2532
26− return new Intl.DateTimeFormat(undefined, {
27− dateStyle: "medium",
28− timeStyle: "short",
29− }).format(value);
33+const formatTimestamp = (value: number | null): string => {
34+ if (value === null) return "—";
35+ const d = new Date(value);
36+ const month = d.toLocaleString(undefined, { month: "short" });
37+ const day = d.getDate();
38+ const h = d.getHours().toString().padStart(2, "0");
39+ const m = d.getMinutes().toString().padStart(2, "0");
40+ const s = d.getSeconds().toString().padStart(2, "0");
41+ return `${month} ${day}, ${h}:${m}:${s}`;
3042 };
3143
3244 const formatDuration = (execution: Execution): string | null => {
33− if (execution.startedAt === null || execution.completedAt === null) {
34− return null;
35− }
36−
37− const durationMs = Math.max(0, execution.completedAt - execution.startedAt);
38− if (durationMs < 1_000) {
39− return `${durationMs} ms`;
40− }
41−
42− if (durationMs < 60_000) {
43− return `${(durationMs / 1_000).toFixed(1)} s`;
44− }
45−
46− return `${(durationMs / 60_000).toFixed(1)} min`;
45+ if (execution.startedAt === null || execution.completedAt === null) return null;
46+ const ms = Math.max(0, execution.completedAt - execution.startedAt);
47+ if (ms < 1_000) return `${ms}ms`;
48+ if (ms < 60_000) return `${(ms / 1_000).toFixed(1)}s`;
49+ return `${(ms / 60_000).toFixed(1)}m`;
4750 };
4851
49−const summarizeCode = (code: string): string =>
50− code
51− .trim()
52− .replace(/\s+/g, " ")
53− .slice(0, 220);
52+const truncateCode = (code: string, max = 80): string =>
53+ code.trim().replace(/\s+/g, " ").slice(0, max);
5454
5555 const formatJsonDocument = (value: string | null): string | null => {
56− if (value === null) {
57− return null;
58− }
59−
56+ if (value === null) return null;
6057 try {
6158 return JSON.stringify(JSON.parse(value), null, 2);
6259 } catch {
@@ -64,136 +61,160 @@ const formatJsonDocument = (value: string | null): string | null => { · 1 unmodified line
6461 }
6562 };
6663
67−const statusVariant = (
68− status: Execution["status"],
69−): "default" | "muted" | "outline" | "destructive" => {
64+const statusDotColor = (status: ExecutionStatus): string => {
65+ switch (status) {
66+ case "completed":
67+ return "bg-emerald-400";
68+ case "failed":
69+ return "bg-red-400";
70+ case "running":
71+ return "bg-blue-400 animate-pulse";
72+ case "waiting_for_interaction":
73+ return "bg-amber-400 animate-pulse";
74+ case "pending":
75+ return "bg-zinc-500";
76+ case "cancelled":
77+ return "bg-zinc-500";
78+ }
79+};
80+
81+const STATUS_OPTIONS: Array<{ value: string; label: string }> = [
82+ { value: "all", label: "All" },
83+ { value: "completed", label: "Completed" },
84+ { value: "failed", label: "Failed" },
85+ { value: "running", label: "Running" },
86+ { value: "waiting_for_interaction", label: "Waiting" },
87+ { value: "pending", label: "Pending" },
88+ { value: "cancelled", label: "Cancelled" },
89+];
90+
91+const formatDurationMs = (execution: Execution): string | null => {
92+ if (execution.startedAt === null || execution.completedAt === null) return null;
93+ const ms = Math.max(0, execution.completedAt - execution.startedAt);
94+ return ms.toLocaleString();
95+};
96+
97+const statusColor = (status: ExecutionStatus): string => {
7098 switch (status) {
7199 case "completed":
72− return "default";
100+ return "text-emerald-400";
73101 case "failed":
74− return "destructive";
102+ return "text-red-400";
75103 case "running":
104+ return "text-blue-400";
76105 case "waiting_for_interaction":
77− return "outline";
106+ return "text-amber-400";
78107 default:
79− return "muted";
108+ return "text-zinc-400";
80109 }
81110 };
82111
83−const MetadataItem = (props: {
84− label: string;
85− value: string;
86−}) => (
87− <Card className="bg-card/60 px-3 py-2">
88− <div className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/70">
89− {props.label}
90− </div>
91− <div className="mt-1 text-sm text-foreground">{props.value}</div>
92− </Card>
112+// ── Execution Row ────────────────────────────────────────────────────────
113+
114+const KeyValue = (props: { k: string; v: string; vClass?: string }) => (
115+ <>
116+ <span className="text-muted-foreground/60">{props.k}:</span>{" "}
117+ <span className={props.vClass ?? "text-emerald-400"}>{props.v}</span>{" "}
118+ </>
93119 );
94120
95−const ExecutionCard = (props: {
121+const ExecutionRow = (props: {
96122 execution: Execution;
97− onOpen: () => void;
123+ isSelected: boolean;
124+ onSelect: () => void;
98125 }) => {
99− const { execution } = props;
126+ const { execution, isSelected } = props;
127+ const durationMs = formatDurationMs(execution);
100128
101129 return (
102− <Button
103− variant="ghost"
130+ <button
104131 type="button"
105− onClick={props.onOpen}
106− className="h-auto w-full rounded-2xl border border-border bg-card px-5 py-4 text-left transition-colors hover:border-primary/25 hover:bg-card/90"
107− >
108− <div className="flex items-start justify-between gap-3">
109− <div className="min-w-0 flex-1">
110− <div className="flex items-center gap-2">
111− <div className="truncate text-sm font-semibold text-foreground">
112− {execution.id}
113− </div>
114− <Badge variant={statusVariant(execution.status)}>
115− {execution.status.replaceAll("_", " ")}
116− </Badge>
117− </div>
118− <div className="mt-1 text-xs text-muted-foreground">
119− Created {formatTimestamp(execution.createdAt)}
120− {formatDuration(execution) ? ` • ${formatDuration(execution)}` : ""}
121− </div>
122− <p className="mt-3 line-clamp-3 text-sm leading-6 text-muted-foreground">
123− {summarizeCode(execution.code) || "No code captured."}
124− </p>
125− </div>
126− </div>
127−
128− {execution.errorText && (
129− <Alert variant="destructive" className="mt-3 text-xs">
130− {execution.errorText}
131− </Alert>
132+ onClick={props.onSelect}
133+ className={cn(
134+ "group flex w-full items-start gap-3 border-b border-border/50 px-4 py-2 text-left font-mono text-xs transition-colors hover:bg-white/[0.04]",
135+ isSelected && "bg-white/[0.06]",
132136 )}
133− </Button>
137+ >
138+ <span
139+ className={cn(
140+ "mt-1.5 size-2 shrink-0 rounded-full",
141+ statusDotColor(execution.status),
142+ )}
143+ />
144+ <span className="w-[120px] shrink-0 tabular-nums text-muted-foreground">
145+ {formatTimestamp(execution.createdAt)}
146+ </span>
147+ <span className="inline-flex w-[130px] shrink-0">
148+ <span className="text-muted-foreground/60">status:</span>
149+ <span className={statusColor(execution.status)}>{execution.status.replaceAll("_", " ")}</span>
150+ </span>
151+ <span className="inline-flex w-[110px] shrink-0">
152+ <span className="text-muted-foreground/60">duration_ms:</span>{" "}
153+ <span className={durationMs && Number(durationMs.replace(/,/g, "")) > 5000 ? "text-red-400" : "text-emerald-400"}>{durationMs ?? "—"}</span>
154+ </span>
155+ <span className="min-w-0 flex-1 truncate">
156+ <span className="text-muted-foreground/60">code:</span>{" "}
157+ <span className="text-muted-foreground">&quot;{truncateCode(execution.code, 120)}…&quot;</span>
158+ </span>
159+ </button>
134160 );
135161 };
136162
137−export function ExecutionHistoryPage() {
138− const executions = useExecutions();
139− const navigation = useExecutorPluginNavigation();
163+// ── Detail Drawer ────────────────────────────────────────────────────────
140164
141− return (
142− <div className="h-full overflow-y-auto">
143− <div className="mx-auto max-w-5xl px-6 py-10 lg:px-10 lg:py-14">
144− <div className="mb-8">
145− <h1 className="font-display text-3xl tracking-tight text-foreground lg:text-4xl">
146− Execution history
147− </h1>
148− <p className="mt-1.5 text-[14px] text-muted-foreground">
149− Every execution recorded for this workspace, newest first.
150− </p>
151− </div>
165+type DetailTab = "properties" | "logs";
152166
153− <LoadableBlock loadable={executions} loading="Loading executions...">
154− {(items) =>
155− items.length === 0 ? (
156− <div className="rounded-2xl border border-dashed border-border py-20">
157− <EmptyState
158− title="No executions yet"
159− description="Run something and it will show up here."
160− />
161− </div>
162− ) : (
163− <div className="grid gap-3">
164− {items.map((execution) => (
165− <ExecutionCard
166− key={execution.id}
167− execution={execution}
168− onOpen={() => {
169− void navigation.route(execution.id);
170− }}
171− />
172− ))}
173− </div>
174− )
175− }
176− </LoadableBlock>
177− </div>
178− </div>
179− );
180−}
167+const DetailTabButton = (props: {
168+ label: string;
169+ active: boolean;
170+ onClick: () => void;
171+}) => (
172+ <button
173+ type="button"
174+ onClick={props.onClick}
175+ className={cn(
176+ "px-3 py-1.5 text-sm font-medium transition-colors",
177+ props.active
178+ ? "border-b-2 border-primary text-foreground"
179+ : "text-muted-foreground hover:text-foreground",
180+ )}
181+ >
182+ {props.label}
183+ </button>
184+);
181185
182−const ExecutionDetailSections = (props: {
183− envelope: ExecutionEnvelope;
184−}) => {
186+const PropertiesTab = (props: { envelope: ExecutionEnvelope }) => {
185187 const { execution, pendingInteraction } = props.envelope;
186188
187189 return (
188− <div className="space-y-6">
189− <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
190− <MetadataItem label="Status" value={execution.status.replaceAll("_", " ")} />
191− <MetadataItem label="Created" value={formatTimestamp(execution.createdAt)} />
192− <MetadataItem label="Started" value={formatTimestamp(execution.startedAt)} />
193− <MetadataItem
194− label="Completed"
195− value={formatTimestamp(execution.completedAt)}
196− />
190+ <div className="space-y-4">
191+ <div className="grid grid-cols-2 gap-3">
192+ <div className="rounded-lg border border-border/50 px-3 py-2">
193+ <div className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/70">
194+ Status
195+ </div>
196+ <div className="mt-1 flex items-center gap-2">
197+ <span className={cn("size-2 rounded-full", statusDotColor(execution.status))} />
198+ <span className="text-sm">{execution.status.replaceAll("_", " ")}</span>
199+ </div>
200+ </div>
201+ <div className="rounded-lg border border-border/50 px-3 py-2">
202+ <div className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/70">
203+ Duration
204+ </div>
205+ <div className="mt-1 text-sm">{formatDuration(execution) ?? "—"}</div>
206+ </div>
207+ </div>
208+
209+ <div className="grid grid-cols-2 gap-3 text-xs text-muted-foreground">
210+ <div>
211+ <span className="text-muted-foreground/60">Created </span>
212+ {formatTimestamp(execution.createdAt)}
213+ </div>
214+ <div>
215+ <span className="text-muted-foreground/60">Started </span>
216+ {formatTimestamp(execution.startedAt)}
217+ </div>
197218 </div>
198219
199220 <DocumentPanel
@@ -203,20 +224,12 @@ const ExecutionDetailSections = (props: { · 3 unmodified lines
203224 empty="No code captured."
204225 />
205226
206− <div className="grid gap-4 xl:grid-cols-2">
207− <DocumentPanel
208− title="Result"
209− body={formatJsonDocument(execution.resultJson)}
210− lang="json"
211− empty="No result recorded."
212− />
213− <DocumentPanel
214− title="Logs"
215− body={formatJsonDocument(execution.logsJson)}
216− lang="json"
217− empty="No logs recorded."
218− />
219− </div>
227+ <DocumentPanel
228+ title="Result"
229+ body={formatJsonDocument(execution.resultJson)}
230+ lang="json"
231+ empty="No result recorded."
232+ />
220233
221234 {execution.errorText && (
222235 <DocumentPanel
@@ -228,37 +241,25 @@ const ExecutionDetailSections = (props: { · 5 unmodified lines
228241 )}
229242
230243 {pendingInteraction && (
231− <div className="space-y-4">
232− <div>
233− <h2 className="text-sm font-semibold text-foreground">
234− Pending interaction
235− </h2>
236− <p className="mt-1 text-sm text-muted-foreground">
237− {pendingInteraction.kind} • {pendingInteraction.purpose}
238− </p>
244+ <div className="space-y-3">
245+ <div className="text-sm font-medium text-foreground">
246+ Pending interaction
247+ <span className="ml-2 text-xs text-muted-foreground">
248+ {pendingInteraction.kind} — {pendingInteraction.purpose}
249+ </span>
239250 </div>
240−
241− <div className="grid gap-3 sm:grid-cols-3">
242− <MetadataItem label="Kind" value={pendingInteraction.kind} />
243− <MetadataItem label="Purpose" value={pendingInteraction.purpose} />
244− <MetadataItem
245− label="Status"
246− value={pendingInteraction.status.replaceAll("_", " ")}
247− />
248− </div>
249−
250− <div className="grid gap-4 xl:grid-cols-2">
251+ <div className="grid gap-3 xl:grid-cols-2">
251252 <DocumentPanel
252253 title="Request"
253254 body={formatJsonDocument(pendingInteraction.payloadJson)}
254255 lang="json"
255− empty="No interaction request recorded."
256+ empty="No request payload."
256257 />
257258 <DocumentPanel
258259 title="Response"
259260 body={formatJsonDocument(pendingInteraction.responseJson)}
260261 lang="json"
261− empty="No interaction response recorded."
262+ empty="No response yet."
262263 />
263264 </div>
264265 </div>
@@ -267,6 +268,315 @@ const ExecutionDetailSections = (props: { · 2 unmodified lines
267268 );
268269 };
269270
271+const LogsTab = (props: { logsJson: string | null }) => {
272+ const parsed = useMemo(() => {
273+ if (!props.logsJson) return null;
274+ try {
275+ const arr = JSON.parse(props.logsJson);
276+ if (!Array.isArray(arr)) return null;
277+ return arr as string[];
278+ } catch {
279+ return null;
280+ }
281+ }, [props.logsJson]);
282+
283+ if (!parsed) {
284+ return (
285+ <DocumentPanel
286+ title="Logs"
287+ body={formatJsonDocument(props.logsJson)}
288+ lang="json"
289+ empty="No logs recorded."
290+ />
291+ );
292+ }
293+
294+ return (
295+ <div className="rounded-lg border border-border/50 bg-card/40 p-3">
296+ <div className="mb-2 text-[10px] font-medium uppercase tracking-wider text-muted-foreground/70">
297+ Logs
298+ </div>
299+ <div className="space-y-0.5 font-mono text-xs">
300+ {parsed.map((line, i) => {
301+ const isError = typeof line === "string" && /\[error]/i.test(line);
302+ const isWarn = typeof line === "string" && /\[warn]/i.test(line);
303+ return (
304+ <div
305+ key={i}
306+ className={cn(
307+ "whitespace-pre-wrap break-all",
308+ isError && "text-red-400",
309+ isWarn && "text-amber-400",
310+ !isError && !isWarn && "text-foreground/80",
311+ )}
312+ >
313+ {typeof line === "string" ? line : JSON.stringify(line)}
314+ </div>
315+ );
316+ })}
317+ </div>
318+ </div>
319+ );
320+};
321+
322+const ExecutionDetailDrawer = (props: {
323+ executionId: string;
324+ onClose: () => void;
325+}) => {
326+ const [tab, setTab] = useState<DetailTab>("properties");
327+ const execution = useExecution(props.executionId);
328+ const [copied, setCopied] = useState(false);
329+
330+ const handleCopy = useCallback(
331+ (envelope: ExecutionEnvelope) => {
332+ void navigator.clipboard
333+ .writeText(JSON.stringify(envelope, null, 2))
334+ .then(() => {
335+ setCopied(true);
336+ setTimeout(() => setCopied(false), 1500);
337+ });
338+ },
339+ [],
340+ );
341+
342+ return (
343+ <Dialog open onOpenChange={(open) => !open && props.onClose()}>
344+ <DialogPortal>
345+ <DialogOverlay />
346+ <DialogPopup
347+ className="fixed inset-y-0 right-0 z-50 flex w-full max-w-3xl flex-col bg-popover text-popover-foreground ring-1 ring-foreground/10 outline-none duration-150 data-open:animate-in data-open:slide-in-from-right data-closed:animate-out data-closed:slide-out-to-right"
348+ >
349+ {/* Header */}
350+ <div className="flex items-center justify-between border-b border-border/50 px-5 py-3">
351+ <div className="min-w-0">
352+ <div className="truncate text-sm font-medium text-foreground">
353+ {props.executionId}
354+ </div>
355+ </div>
356+ <div className="flex items-center gap-2">
357+ <LoadableBlock loadable={execution} loading="">
358+ {(envelope) => (
359+ <Button
360+ variant="ghost"
361+ size="sm"
362+ onClick={() => handleCopy(envelope)}
363+ className="text-xs text-muted-foreground"
364+ >
365+ {copied ? "Copied" : "Copy JSON"}
366+ </Button>
367+ )}
368+ </LoadableBlock>
369+ <Button
370+ variant="ghost"
371+ size="icon-sm"
372+ onClick={props.onClose}
373+ >
374+ <IconClose className="size-3.5" />
375+ </Button>
376+ </div>
377+ </div>
378+
379+ {/* Tabs */}
380+ <div className="flex gap-1 border-b border-border/50 px-5">
381+ <DetailTabButton
382+ label="Properties"
383+ active={tab === "properties"}
384+ onClick={() => setTab("properties")}
385+ />
386+ <DetailTabButton
387+ label="Logs"
388+ active={tab === "logs"}
389+ onClick={() => setTab("logs")}
390+ />
391+ </div>
392+
393+ {/* Content */}
394+ <div className="flex-1 overflow-y-auto px-5 py-4">
395+ <LoadableBlock loadable={execution} loading="Loading execution...">
396+ {(envelope) =>
397+ tab === "properties" ? (
398+ <PropertiesTab envelope={envelope} />
399+ ) : (
400+ <LogsTab logsJson={envelope.execution.logsJson} />
401+ )
402+ }
403+ </LoadableBlock>
404+ </div>
405+ </DialogPopup>
406+ </DialogPortal>
407+ </Dialog>
408+ );
409+};
410+
411+// ── List Page ────────────────────────────────────────────────────────────
412+
413+type ExecutionHistorySearch = {
414+ executionId?: string;
415+};
416+
417+export function ExecutionHistoryPage() {
418+ const executions = useExecutions();
419+ const navigation = useExecutorPluginNavigation();
420+ const searchState = useExecutorPluginSearch<ExecutionHistorySearch>();
421+ const [statusFilter, setStatusFilter] = useState<string>("all");
422+ const [search, setSearch] = useState("");
423+ const selectedId =
424+ typeof searchState.executionId === "string"
425+ ? searchState.executionId
426+ : null;
427+
428+ const setSelectedId = useCallback(
429+ (executionId: string | null) => {
430+ if (executionId === null) {
431+ const { executionId: _, ...nextSearch } = searchState;
432+ void navigation.updateSearch(nextSearch);
433+ return;
434+ }
435+
436+ void navigation.updateSearch({
437+ ...searchState,
438+ executionId,
439+ });
440+ },
441+ [navigation, searchState],
442+ );
443+
444+ const filtered = useMemo(() => {
445+ if (executions.status !== "ready") return [];
446+ let items = [...executions.data];
447+ if (statusFilter !== "all") {
448+ items = items.filter((e) => e.status === statusFilter);
449+ }
450+ if (search.trim()) {
451+ const q = search.toLowerCase();
452+ items = items.filter(
453+ (e) =>
454+ e.code.toLowerCase().includes(q) ||
455+ (e.errorText && e.errorText.toLowerCase().includes(q)),
456+ );
457+ }
458+ return items;
459+ }, [executions, statusFilter, search]);
460+
461+ return (
462+ <div className="flex h-full flex-col">
463+ {/* Header */}
464+ <div className="shrink-0 border-b border-border/50 px-6 py-5">
465+ <h1 className="font-display text-2xl tracking-tight text-foreground">
466+ Execution history
467+ </h1>
468+ <p className="mt-1 text-sm text-muted-foreground">
469+ Every execution recorded for this workspace, newest first.
470+ </p>
471+
472+ {/* Filter bar */}
473+ <div className="mt-4 flex items-center gap-3">
474+ <Select
475+ value={statusFilter}
476+ onChange={(e) => setStatusFilter(e.target.value)}
477+ className="w-[140px]"
478+ >
479+ {STATUS_OPTIONS.map((opt) => (
480+ <option key={opt.value} value={opt.value}>
481+ {opt.label}
482+ </option>
483+ ))}
484+ </Select>
485+ <Input
486+ type="text"
487+ placeholder="Search code or errors..."
488+ value={search}
489+ onChange={(e) => setSearch((e.target as HTMLInputElement).value)}
490+ className="flex-1"
491+ />
492+ </div>
493+ </div>
494+
495+ {/* Column headers */}
496+ <div className="flex items-center gap-3 border-b border-border/50 px-4 py-1.5 text-[10px] font-medium uppercase tracking-wider text-muted-foreground/60">
497+ <span className="size-2 shrink-0" />
498+ <span className="w-[120px] shrink-0">_time</span>
499+ <span>Raw Data</span>
500+ </div>
501+
502+ {/* List */}
503+ <div className="flex-1 overflow-y-auto">
504+ <LoadableBlock loadable={executions} loading="Loading executions...">
505+ {() =>
506+ filtered.length === 0 ? (
507+ <div className="py-20">
508+ <EmptyState
509+ title={
510+ statusFilter !== "all" || search.trim()
511+ ? "No matching executions"
512+ : "No executions yet"
513+ }
514+ description={
515+ statusFilter !== "all" || search.trim()
516+ ? "Try adjusting your filters."
517+ : "Run something and it will show up here."
518+ }
519+ />
520+ </div>
521+ ) : (
522+ <div>
523+ {filtered.map((execution) => (
524+ <ExecutionRow
525+ key={execution.id}
526+ execution={execution}
527+ isSelected={selectedId === execution.id}
528+ onSelect={() =>
529+ setSelectedId(
530+ selectedId === execution.id ? null : execution.id,
531+ )
532+ }
533+ />
534+ ))}
535+ </div>
536+ )
537+ }
538+ </LoadableBlock>
539+ </div>
540+
541+ {/* Drawer */}
542+ {selectedId && (
543+ <ExecutionDetailDrawer
544+ executionId={selectedId}
545+ onClose={() => setSelectedId(null)}
546+ />
547+ )}
548+ </div>
549+ );
550+}
551+
552+// ── Detail Page (deep-link) ──────────────────────────────────────────────
553+
554+const ExecutionDetailInline = (props: { envelope: ExecutionEnvelope }) => {
555+ const [tab, setTab] = useState<DetailTab>("properties");
556+
557+ return (
558+ <div>
559+ <div className="mb-4 flex gap-1 border-b border-border/50">
560+ <DetailTabButton
561+ label="Properties"
562+ active={tab === "properties"}
563+ onClick={() => setTab("properties")}
564+ />
565+ <DetailTabButton
566+ label="Logs"
567+ active={tab === "logs"}
568+ onClick={() => setTab("logs")}
569+ />
570+ </div>
571+ {tab === "properties" ? (
572+ <PropertiesTab envelope={props.envelope} />
573+ ) : (
574+ <LogsTab logsJson={props.envelope.execution.logsJson} />
575+ )}
576+ </div>
577+ );
578+};
579+
270580 export function ExecutionHistoryDetailPage() {
271581 const { executionId } = useExecutorPluginRouteParams<{
272582 executionId: string;
@@ -276,29 +586,29 @@ export function ExecutionHistoryDetailPage() { · 3 unmodified lines
276586
277587 return (
278588 <div className="h-full overflow-y-auto">
279− <div className="mx-auto max-w-6xl px-6 py-10 lg:px-10 lg:py-14">
280− <div className="mb-8 flex flex-wrap items-center justify-between gap-4">
281− <div>
282− <Button
283− variant="ghost"
284− type="button"
285− onClick={() => {
286− void navigation.route();
287− }}
288− className="mb-3 h-auto p-0 text-xs font-medium uppercase tracking-wider text-muted-foreground transition-colors hover:text-foreground"
289− >
290− Back to history
291− </Button>
292− <div className="flex items-center gap-3">
293− <h1 className="font-display text-2xl tracking-tight text-foreground lg:text-3xl">
294− {executionId}
295− </h1>
296− </div>
589+ <div className="mx-auto max-w-5xl px-6 py-10 lg:px-10 lg:py-14">
590+ <div className="mb-8">
591+ <Button
592+ variant="ghost"
593+ type="button"
594+ onClick={() => {
595+ void navigation.route();
596+ }}
597+ className="mb-3 h-auto p-0 text-xs font-medium uppercase tracking-wider text-muted-foreground transition-colors hover:text-foreground"
598+ >
599+ Back to history
600+ </Button>
601+ <div className="flex items-center gap-3">
602+ <h1 className="font-display text-2xl tracking-tight text-foreground lg:text-3xl">
603+ {executionId}
604+ </h1>
297605 </div>
298606 </div>
299607
300608 <LoadableBlock loadable={execution} loading="Loading execution...">
301− {(envelope) => <ExecutionDetailSections envelope={envelope} />}
609+ {(envelope) => (
610+ <ExecutionDetailInline envelope={envelope} />
611+ )}
302612 </LoadableBlock>
303613 </div>
304614 </div>