History

Merge pull request #62 from aryasaatvik/feat/execution-history-ui

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

Rhys Sullivan committed 7 months ago

Commit be2130b2 parents 0e98853 52329dc5 files · +507 −199Browse files

Unified · Split

5 files
  1. bun.lock +4 −1
  2. package.json +1 −0
  3. packages/clients/react/src/plugins/components/ui/dialog.tsx +5 −0
  4. packages/clients/react/src/plugins/index.ts +1 −0
  5. plugins/execution-history/react/components.tsx +496 −198
bun.lock+4−1
@@ -6,6 +6,7 @@ · 5 unmodified lines
66 "name": "executor",
77 "devDependencies": {
88 "@changesets/cli": "^2.30.0",
9+ "@oxlint/plugins": "^1.57.0",
910 "knip": "^5.85.0",
1011 "oxlint": "^1.56.0",
1112 "turbo": "^2.5.6",
@@ -18,7 +19,7 @@ · 6 unmodified lines
1819 },
1920 "apps/executor": {
2021 "name": "executor",
21− "version": "1.2.6",
22+ "version": "1.3.0-beta.0",
2223 "bin": {
2324 "executor": "bin/executor",
2425 },
@@ -1357,6 +1358,8 @@ · 1332 unmodified lines
13571358
13581359 "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.56.0", "", { "os": "win32", "cpu": "x64" }, "sha512-ZHa0clocjLmIDr+1LwoWtxRcoYniAvERotvwKUYKhH41NVfl0Y4LNbyQkwMZzwDvKklKGvGZ5+DAG58/Ik47tQ=="],
13591360
1361+ "@oxlint/plugins": ["@oxlint/plugins@1.57.0", "", {}, "sha512-4mAGdfUZNQSZwUbHs0xoUcKjByrdBSJ9iaCI3STn/d1ZVRKhW/82oCA6rdfSzO4vxrWH3/l+qYWh/tyrwPSpag=="],
1362+
13601363 "@parcel/watcher": ["@parcel/watcher@2.5.6", "", { "dependencies": { "detect-libc": "^2.0.3", "is-glob": "^4.0.3", "node-addon-api": "^7.0.0", "picomatch": "^4.0.3" }, "optionalDependencies": { "@parcel/watcher-android-arm64": "2.5.6", "@parcel/watcher-darwin-arm64": "2.5.6", "@parcel/watcher-darwin-x64": "2.5.6", "@parcel/watcher-freebsd-x64": "2.5.6", "@parcel/watcher-linux-arm-glibc": "2.5.6", "@parcel/watcher-linux-arm-musl": "2.5.6", "@parcel/watcher-linux-arm64-glibc": "2.5.6", "@parcel/watcher-linux-arm64-musl": "2.5.6", "@parcel/watcher-linux-x64-glibc": "2.5.6", "@parcel/watcher-linux-x64-musl": "2.5.6", "@parcel/watcher-win32-arm64": "2.5.6", "@parcel/watcher-win32-ia32": "2.5.6", "@parcel/watcher-win32-x64": "2.5.6" } }, "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ=="],
13611364
13621365 "@parcel/watcher-android-arm64": ["@parcel/watcher-android-arm64@2.5.6", "", { "os": "android", "cpu": "arm64" }, "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A=="],
package.json+1−0
@@ -42,6 +42,7 @@ · 41 unmodified lines
4242 },
4343 "devDependencies": {
4444 "@changesets/cli": "^2.30.0",
45+ "@oxlint/plugins": "^1.57.0",
4546 "knip": "^5.85.0",
4647 "oxlint": "^1.56.0",
4748 "turbo": "^2.5.6",
packages/clients/react/src/plugins/components/ui/dialog.tsx+5−0
@@ -145,6 +145,10 @@ function DialogDescription({ · 144 unmodified lines
145145 )
146146 }
147147
148+function DialogPopup({ ...props }: DialogPrimitive.Popup.Props) {
149+ return <DialogPrimitive.Popup data-slot="dialog-popup" {...props} />
150+}
151+
148152 export {
149153 Dialog,
150154 DialogClose,
@@ -153,6 +157,7 @@ export { · 2 unmodified lines
153157 DialogFooter,
154158 DialogHeader,
155159 DialogOverlay,
160+ DialogPopup,
156161 DialogPortal,
157162 DialogTitle,
158163 DialogTrigger,
packages/clients/react/src/plugins/index.ts+1−0
@@ -123,3 +123,4 @@ export { Separator } from "./components/ui/separator"; · 122 unmodified lines
123123 export { Textarea } from "./components/ui/textarea";
124124 export { Popover, PopoverContent, PopoverTrigger } from "./components/ui/popover";
125125 export { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem, CommandShortcut, CommandSeparator } from "./components/ui/command";
126+export { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogOverlay, DialogPopup, DialogPortal, DialogTitle, DialogTrigger } from "./components/ui/dialog";
plugins/execution-history/react/components.tsx+496−198
@@ -1,3 +1,4 @@
1+import { useState, useMemo, useCallback } from "react";
12 import type {
23 Execution,
34 ExecutionEnvelope,
@@ -7,56 +8,51 @@ import { · 3 unmodified lines
78 useExecutions,
89 } from "@executor/react";
910 import {
10− Alert,
11− Badge,
1211 Button,
13− Card,
12+ cn,
13+ Dialog,
14+ DialogOverlay,
15+ DialogPopup,
16+ DialogPortal,
1417 DocumentPanel,
1518 EmptyState,
19+ IconClose,
20+ Input,
1621 LoadableBlock,
22+ Select,
1723 useExecutorPluginNavigation,
1824 useExecutorPluginRouteParams,
25+ useExecutorPluginSearch,
1926 } from "@executor/react/plugins";
2027
21−const formatTimestamp = (value: number | null): string => {
22− if (value === null) {
23− return "—";
24− }
28+// ── Helpers ──────────────────────────────────────────────────────────────
29+
30+type ExecutionStatus = Execution["status"];
2531
26− return new Intl.DateTimeFormat(undefined, {
27− dateStyle: "medium",
28− timeStyle: "short",
29− }).format(value);
32+const formatTimestamp = (value: number | null): string => {
33+ if (value === null) return "—";
34+ const d = new Date(value);
35+ const month = d.toLocaleString(undefined, { month: "short" });
36+ const day = d.getDate();
37+ const h = d.getHours().toString().padStart(2, "0");
38+ const m = d.getMinutes().toString().padStart(2, "0");
39+ const s = d.getSeconds().toString().padStart(2, "0");
40+ return `${month} ${day}, ${h}:${m}:${s}`;
3041 };
3142
3243 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`;
44+ if (execution.startedAt === null || execution.completedAt === null) return null;
45+ const ms = Math.max(0, execution.completedAt - execution.startedAt);
46+ if (ms < 1_000) return `${ms}ms`;
47+ if (ms < 60_000) return `${(ms / 1_000).toFixed(1)}s`;
48+ return `${(ms / 60_000).toFixed(1)}m`;
4749 };
4850
49−const summarizeCode = (code: string): string =>
50− code
51− .trim()
52− .replace(/\s+/g, " ")
53− .slice(0, 220);
51+const truncateCode = (code: string, max = 80): string =>
52+ code.trim().replace(/\s+/g, " ").slice(0, max);
5453
5554 const formatJsonDocument = (value: string | null): string | null => {
56− if (value === null) {
57− return null;
58− }
59−
55+ if (value === null) return null;
6056 try {
6157 return JSON.stringify(JSON.parse(value), null, 2);
6258 } catch {
@@ -64,136 +60,133 @@ const formatJsonDocument = (value: string | null): string | null => { · 1 unmodified line
6460 }
6561 };
6662
67−const statusVariant = (
68− status: Execution["status"],
69−): "default" | "muted" | "outline" | "destructive" => {
70− switch (status) {
71− case "completed":
72− return "default";
73− case "failed":
74− return "destructive";
75− case "running":
76− case "waiting_for_interaction":
77− return "outline";
78− default:
79− return "muted";
80− }
63+const STATUS_STYLES = {
64+ completed: { dot: "bg-primary", text: "text-primary" },
65+ failed: { dot: "bg-destructive", text: "text-destructive" },
66+ running: { dot: "bg-blue-400 animate-pulse", text: "text-blue-400" },
67+ waiting_for_interaction: { dot: "bg-amber-400 animate-pulse", text: "text-amber-400" },
68+ pending: { dot: "bg-muted", text: "text-muted-foreground" },
69+ cancelled: { dot: "bg-muted", text: "text-muted-foreground" },
70+} as const satisfies Record<ExecutionStatus, { dot: string; text: string }>;
71+
72+const statusDot = (status: ExecutionStatus): string => STATUS_STYLES[status].dot;
73+const statusText = (status: ExecutionStatus): string => STATUS_STYLES[status].text;
74+
75+const STATUS_OPTIONS: Array<{ value: "all" | ExecutionStatus; label: string }> = [
76+ { value: "all", label: "All" },
77+ { value: "completed", label: "Completed" },
78+ { value: "failed", label: "Failed" },
79+ { value: "running", label: "Running" },
80+ { value: "waiting_for_interaction", label: "Waiting" },
81+ { value: "pending", label: "Pending" },
82+ { value: "cancelled", label: "Cancelled" },
83+];
84+
85+const formatDurationMs = (execution: Execution): string | null => {
86+ if (execution.startedAt === null || execution.completedAt === null) return null;
87+ const ms = Math.max(0, execution.completedAt - execution.startedAt);
88+ return ms.toLocaleString();
8189 };
8290
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>
93−);
91+// ── Execution Row ────────────────────────────────────────────────────────
9492
95−const ExecutionCard = (props: {
93+const ExecutionRow = (props: {
9694 execution: Execution;
97− onOpen: () => void;
95+ isSelected: boolean;
96+ onSelect: () => void;
9897 }) => {
99− const { execution } = props;
98+ const { execution, isSelected } = props;
99+ const durationMs = formatDurationMs(execution);
100100
101101 return (
102− <Button
103− variant="ghost"
102+ <button
104103 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>
104+ onClick={props.onSelect}
105+ className={cn(
106+ "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]",
107+ isSelected && "bg-white/[0.06]",
132108 )}
133− </Button>
109+ >
110+ <span
111+ className={cn(
112+ "mt-1.5 size-2 shrink-0 rounded-full",
113+ statusDot(execution.status),
114+ )}
115+ />
116+ <span className="w-[120px] shrink-0 tabular-nums text-muted-foreground">
117+ {formatTimestamp(execution.createdAt)}
118+ </span>
119+ <span className="inline-flex w-[130px] shrink-0">
120+ <span className="text-muted-foreground/60">status:</span>
121+ <span className={statusText(execution.status)}>{execution.status.replaceAll("_", " ")}</span>
122+ </span>
123+ <span className="inline-flex w-[150px] shrink-0">
124+ <span className="text-muted-foreground/60">duration_ms:</span>{" "}
125+ <span className={durationMs && Number(durationMs.replace(/,/g, "")) > 5000 ? "text-destructive" : "text-primary"}>{durationMs ?? "—"}</span>
126+ </span>
127+ <span className="min-w-0 flex-1 truncate">
128+ <span className="text-muted-foreground/60">code:</span>{" "}
129+ <span className="text-muted-foreground">&quot;{truncateCode(execution.code, 120)}…&quot;</span>
130+ </span>
131+ </button>
134132 );
135133 };
136134
137−export function ExecutionHistoryPage() {
138− const executions = useExecutions();
139− const navigation = useExecutorPluginNavigation();
135+// ── Detail Drawer ────────────────────────────────────────────────────────
140136
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>
137+type DetailTab = "properties" | "logs";
152138
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−}
139+const DetailTabButton = (props: {
140+ label: string;
141+ active: boolean;
142+ onClick: () => void;
143+}) => (
144+ <button
145+ type="button"
146+ onClick={props.onClick}
147+ className={cn(
148+ "px-3 py-1.5 text-sm font-medium transition-colors",
149+ props.active
150+ ? "border-b-2 border-primary text-foreground"
151+ : "text-muted-foreground hover:text-foreground",
152+ )}
153+ >
154+ {props.label}
155+ </button>
156+);
181157
182−const ExecutionDetailSections = (props: {
183− envelope: ExecutionEnvelope;
184−}) => {
158+const PropertiesTab = (props: { envelope: ExecutionEnvelope }) => {
185159 const { execution, pendingInteraction } = props.envelope;
186160
187161 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− />
162+ <div className="space-y-4">
163+ <div className="grid grid-cols-2 gap-3">
164+ <div className="rounded-lg border border-border/50 px-3 py-2">
165+ <div className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/70">
166+ Status
167+ </div>
168+ <div className="mt-1 flex items-center gap-2">
169+ <span className={cn("size-2 rounded-full", statusDot(execution.status))} />
170+ <span className="text-sm">{execution.status.replaceAll("_", " ")}</span>
171+ </div>
172+ </div>
173+ <div className="rounded-lg border border-border/50 px-3 py-2">
174+ <div className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/70">
175+ Duration
176+ </div>
177+ <div className="mt-1 text-sm">{formatDuration(execution) ?? "—"}</div>
178+ </div>
179+ </div>
180+
181+ <div className="grid grid-cols-2 gap-3 text-xs text-muted-foreground">
182+ <div>
183+ <span className="text-muted-foreground/60">Created </span>
184+ {formatTimestamp(execution.createdAt)}
185+ </div>
186+ <div>
187+ <span className="text-muted-foreground/60">Started </span>
188+ {formatTimestamp(execution.startedAt)}
189+ </div>
197190 </div>
198191
199192 <DocumentPanel
@@ -203,20 +196,12 @@ const ExecutionDetailSections = (props: { · 3 unmodified lines
203196 empty="No code captured."
204197 />
205198
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>
199+ <DocumentPanel
200+ title="Result"
201+ body={formatJsonDocument(execution.resultJson)}
202+ lang="json"
203+ empty="No result recorded."
204+ />
220205
221206 {execution.errorText && (
222207 <DocumentPanel
@@ -228,26 +213,14 @@ const ExecutionDetailSections = (props: { · 5 unmodified lines
228213 )}
229214
230215 {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>
216+ <div className="space-y-3">
217+ <div className="text-sm font-medium text-foreground">
218+ Pending interaction
219+ <span className="ml-2 text-xs text-muted-foreground">
220+ {pendingInteraction.kind} — {pendingInteraction.purpose}
221+ </span>
239222 </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">
223+ <div className="grid gap-3 xl:grid-cols-2">
251224 <DocumentPanel
252225 title="Request"
253226 body={formatJsonDocument(pendingInteraction.payloadJson)}
@@ -267,6 +240,331 @@ const ExecutionDetailSections = (props: { · 13 unmodified lines
267240 );
268241 };
269242
243+const LogsTab = (props: { logsJson: string | null }) => {
244+ const parsed = useMemo(() => {
245+ if (!props.logsJson) return null;
246+ try {
247+ const arr = JSON.parse(props.logsJson);
248+ if (!Array.isArray(arr)) return null;
249+ return arr as string[];
250+ } catch {
251+ return null;
252+ }
253+ }, [props.logsJson]);
254+
255+ if (!parsed) {
256+ return (
257+ <DocumentPanel
258+ title="Logs"
259+ body={formatJsonDocument(props.logsJson)}
260+ lang="json"
261+ empty="No logs recorded."
262+ />
263+ );
264+ }
265+
266+ return (
267+ <div className="rounded-lg border border-border/50 bg-card/40 p-3">
268+ <div className="mb-2 text-[10px] font-medium uppercase tracking-wider text-muted-foreground/70">
269+ Logs
270+ </div>
271+ <div className="space-y-0.5 font-mono text-xs">
272+ {parsed.map((line, i) => {
273+ const isError = typeof line === "string" && /\[error]/i.test(line);
274+ const isWarn = typeof line === "string" && /\[warn]/i.test(line);
275+ const lineKey = typeof line === "string"
276+ ? `line-${i}-${line.slice(0, 50).replace(/\s+/g, "-")}`
277+ : `line-${i}-${JSON.stringify(line).slice(0, 50)}`;
278+ return (
279+ <div
280+ key={lineKey}
281+ className={cn(
282+ "whitespace-pre-wrap break-all",
283+ isError && "text-red-400",
284+ isWarn && "text-amber-400",
285+ !isError && !isWarn && "text-foreground/80",
286+ )}
287+ >
288+ {typeof line === "string" ? line : JSON.stringify(line)}
289+ </div>
290+ );
291+ })}
292+ </div>
293+ </div>
294+ );
295+};
296+
297+const ExecutionDetailDrawer = (props: {
298+ executionId: string;
299+ onClose: () => void;
300+}) => {
301+ const [tab, setTab] = useState<DetailTab>("properties");
302+ const execution = useExecution(props.executionId);
303+ const [copied, setCopied] = useState(false);
304+
305+ const handleCopy = useCallback(
306+ (envelope: ExecutionEnvelope) => {
307+ const tryParse = (v: string | null) => {
308+ if (v === null) return null;
309+ try { return JSON.parse(v); } catch { return v; }
310+ };
311+ const cleaned = {
312+ ...envelope,
313+ execution: {
314+ ...envelope.execution,
315+ resultJson: tryParse(envelope.execution.resultJson),
316+ logsJson: tryParse(envelope.execution.logsJson),
317+ errorText: envelope.execution.errorText,
318+ },
319+ };
320+ void navigator.clipboard
321+ .writeText(JSON.stringify(cleaned, null, 2))
322+ .then(() => {
323+ setCopied(true);
324+ setTimeout(() => setCopied(false), 1500);
325+ });
326+ },
327+ [],
328+ );
329+
330+ return (
331+ <Dialog open onOpenChange={(open) => !open && props.onClose()}>
332+ <DialogPortal>
333+ <DialogOverlay />
334+ <DialogPopup
335+ 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"
336+ >
337+ {/* Header */}
338+ <div className="flex items-center justify-between border-b border-border/50 px-5 py-3">
339+ <div className="min-w-0">
340+ <div className="truncate text-sm font-medium text-foreground">
341+ {props.executionId}
342+ </div>
343+ </div>
344+ <div className="flex items-center gap-2">
345+ <LoadableBlock loadable={execution} loading="">
346+ {(envelope) => (
347+ <Button
348+ variant="ghost"
349+ size="sm"
350+ onClick={() => handleCopy(envelope)}
351+ className="text-xs text-muted-foreground"
352+ >
353+ {copied ? "Copied" : "Copy JSON"}
354+ </Button>
355+ )}
356+ </LoadableBlock>
357+ <Button
358+ variant="ghost"
359+ size="icon-sm"
360+ onClick={props.onClose}
361+ >
362+ <IconClose className="size-3.5" />
363+ </Button>
364+ </div>
365+ </div>
366+
367+ {/* Tabs */}
368+ <div className="flex gap-1 border-b border-border/50 px-5">
369+ <DetailTabButton
370+ label="Properties"
371+ active={tab === "properties"}
372+ onClick={() => setTab("properties")}
373+ />
374+ <DetailTabButton
375+ label="Logs"
376+ active={tab === "logs"}
377+ onClick={() => setTab("logs")}
378+ />
379+ </div>
380+
381+ {/* Content */}
382+ <div className="flex-1 overflow-y-auto px-5 py-4">
383+ <LoadableBlock loadable={execution} loading="Loading execution...">
384+ {(envelope) =>
385+ tab === "properties" ? (
386+ <PropertiesTab envelope={envelope} />
387+ ) : (
388+ <LogsTab logsJson={envelope.execution.logsJson} />
389+ )
390+ }
391+ </LoadableBlock>
392+ </div>
393+ </DialogPopup>
394+ </DialogPortal>
395+ </Dialog>
396+ );
397+};
398+
399+// ── List Page ────────────────────────────────────────────────────────────
400+
401+type ExecutionHistorySearch = {
402+ executionId?: string;
403+};
404+
405+export function ExecutionHistoryPage() {
406+ const executions = useExecutions();
407+ const navigation = useExecutorPluginNavigation();
408+ const searchState = useExecutorPluginSearch<ExecutionHistorySearch>();
409+ const [statusFilter, setStatusFilter] = useState<string>("all");
410+ const [search, setSearch] = useState("");
411+ const selectedId =
412+ typeof searchState.executionId === "string"
413+ ? searchState.executionId
414+ : null;
415+
416+ const setSelectedId = useCallback(
417+ (executionId: string | null) => {
418+ if (executionId === null) {
419+ const { executionId: _, ...nextSearch } = searchState;
420+ void navigation.updateSearch(nextSearch);
421+ return;
422+ }
423+
424+ void navigation.updateSearch({
425+ ...searchState,
426+ executionId,
427+ });
428+ },
429+ [navigation, searchState],
430+ );
431+
432+ const filtered = useMemo(() => {
433+ if (executions.status !== "ready") return [];
434+ let items = [...executions.data];
435+ if (statusFilter !== "all") {
436+ items = items.filter((e) => e.status === statusFilter);
437+ }
438+ if (search.trim()) {
439+ const q = search.toLowerCase();
440+ items = items.filter(
441+ (e) =>
442+ e.code.toLowerCase().includes(q) ||
443+ (e.errorText && e.errorText.toLowerCase().includes(q)),
444+ );
445+ }
446+ return items;
447+ }, [executions, statusFilter, search]);
448+
449+ return (
450+ <div className="flex h-full flex-col">
451+ {/* Header */}
452+ <div className="shrink-0 border-b border-border/50 px-6 py-5">
453+ <h1 className="font-display text-2xl tracking-tight text-foreground">
454+ Execution history
455+ </h1>
456+ <p className="mt-1 text-sm text-muted-foreground">
457+ Every execution recorded for this workspace, newest first.
458+ </p>
459+
460+ {/* Filter bar */}
461+ <div className="mt-4 flex items-center gap-3">
462+ <Select
463+ value={statusFilter}
464+ onChange={(e) => setStatusFilter(e.target.value)}
465+ className="w-[140px]"
466+ >
467+ {STATUS_OPTIONS.map((opt) => (
468+ <option key={opt.value} value={opt.value}>
469+ {opt.label}
470+ </option>
471+ ))}
472+ </Select>
473+ <Input
474+ type="text"
475+ placeholder="Search code or errors..."
476+ value={search}
477+ onChange={(e) => setSearch((e.target as HTMLInputElement).value)}
478+ className="flex-1"
479+ />
480+ </div>
481+ </div>
482+
483+ {/* Column headers */}
484+ <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">
485+ <span className="size-2 shrink-0" />
486+ <span className="w-[120px] shrink-0">_time</span>
487+ <span>Raw Data</span>
488+ </div>
489+
490+ {/* List */}
491+ <div className="flex-1 overflow-y-auto">
492+ <LoadableBlock loadable={executions} loading="Loading executions...">
493+ {() =>
494+ filtered.length === 0 ? (
495+ <div className="py-20">
496+ <EmptyState
497+ title={
498+ statusFilter !== "all" || search.trim()
499+ ? "No matching executions"
500+ : "No executions yet"
501+ }
502+ description={
503+ statusFilter !== "all" || search.trim()
504+ ? "Try adjusting your filters."
505+ : "Run something and it will show up here."
506+ }
507+ />
508+ </div>
509+ ) : (
510+ <div>
511+ {filtered.map((execution) => (
512+ <ExecutionRow
513+ key={execution.id}
514+ execution={execution}
515+ isSelected={selectedId === execution.id}
516+ onSelect={() =>
517+ setSelectedId(
518+ selectedId === execution.id ? null : execution.id,
519+ )
520+ }
521+ />
522+ ))}
523+ </div>
524+ )
525+ }
526+ </LoadableBlock>
527+ </div>
528+
529+ {/* Drawer */}
530+ {selectedId && (
531+ <ExecutionDetailDrawer
532+ executionId={selectedId}
533+ onClose={() => setSelectedId(null)}
534+ />
535+ )}
536+ </div>
537+ );
538+}
539+
540+// ── Detail Page (deep-link) ──────────────────────────────────────────────
541+
542+const ExecutionDetailInline = (props: { envelope: ExecutionEnvelope }) => {
543+ const [tab, setTab] = useState<DetailTab>("properties");
544+
545+ return (
546+ <div>
547+ <div className="mb-4 flex gap-1 border-b border-border/50">
548+ <DetailTabButton
549+ label="Properties"
550+ active={tab === "properties"}
551+ onClick={() => setTab("properties")}
552+ />
553+ <DetailTabButton
554+ label="Logs"
555+ active={tab === "logs"}
556+ onClick={() => setTab("logs")}
557+ />
558+ </div>
559+ {tab === "properties" ? (
560+ <PropertiesTab envelope={props.envelope} />
561+ ) : (
562+ <LogsTab logsJson={props.envelope.execution.logsJson} />
563+ )}
564+ </div>
565+ );
566+};
567+
270568 export function ExecutionHistoryDetailPage() {
271569 const { executionId } = useExecutorPluginRouteParams<{
272570 executionId: string;
@@ -276,29 +574,29 @@ export function ExecutionHistoryDetailPage() { · 3 unmodified lines
276574
277575 return (
278576 <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>
577+ <div className="mx-auto max-w-5xl px-6 py-10 lg:px-10 lg:py-14">
578+ <div className="mb-8">
579+ <Button
580+ variant="ghost"
581+ type="button"
582+ onClick={() => {
583+ void navigation.route();
584+ }}
585+ className="mb-3 h-auto p-0 text-xs font-medium uppercase tracking-wider text-muted-foreground transition-colors hover:text-foreground"
586+ >
587+ Back to history
588+ </Button>
589+ <div className="flex items-center gap-3">
590+ <h1 className="font-display text-2xl tracking-tight text-foreground lg:text-3xl">
591+ {executionId}
592+ </h1>
297593 </div>
298594 </div>
299595
300596 <LoadableBlock loadable={execution} loading="Loading execution...">
301− {(envelope) => <ExecutionDetailSections envelope={envelope} />}
597+ {(envelope) => (
598+ <ExecutionDetailInline envelope={envelope} />
599+ )}
302600 </LoadableBlock>
303601 </div>
304602 </div>