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
6 "name": "executor",6 "name": "executor",
7 "devDependencies": {7 "devDependencies": {
8 "@changesets/cli": "^2.30.0",8 "@changesets/cli": "^2.30.0",
9 "@oxlint/plugins": "^1.57.0",
9 "knip": "^5.85.0",10 "knip": "^5.85.0",
10 "oxlint": "^1.56.0",11 "oxlint": "^1.56.0",
11 "turbo": "^2.5.6",12 "turbo": "^2.5.6",
@@ -18,7 +19,7 @@ · 6 unmodified lines
18 },19 },
19 "apps/executor": {20 "apps/executor": {
20 "name": "executor",21 "name": "executor",
21 "version": "1.2.6",22 "version": "1.3.0-beta.0",
22 "bin": {23 "bin": {
23 "executor": "bin/executor",24 "executor": "bin/executor",
24 },25 },
@@ -1357,6 +1358,8 @@ · 1332 unmodified lines
1357 1358
1358 "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.56.0", "", { "os": "win32", "cpu": "x64" }, "sha512-ZHa0clocjLmIDr+1LwoWtxRcoYniAvERotvwKUYKhH41NVfl0Y4LNbyQkwMZzwDvKklKGvGZ5+DAG58/Ik47tQ=="],1359 "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.56.0", "", { "os": "win32", "cpu": "x64" }, "sha512-ZHa0clocjLmIDr+1LwoWtxRcoYniAvERotvwKUYKhH41NVfl0Y4LNbyQkwMZzwDvKklKGvGZ5+DAG58/Ik47tQ=="],
1359 1360
1361 "@oxlint/plugins": ["@oxlint/plugins@1.57.0", "", {}, "sha512-4mAGdfUZNQSZwUbHs0xoUcKjByrdBSJ9iaCI3STn/d1ZVRKhW/82oCA6rdfSzO4vxrWH3/l+qYWh/tyrwPSpag=="],
1362
1360 "@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=="],1363 "@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=="],
1361 1364
1362 "@parcel/watcher-android-arm64": ["@parcel/watcher-android-arm64@2.5.6", "", { "os": "android", "cpu": "arm64" }, "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A=="],1365 "@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
42 },42 },
43 "devDependencies": {43 "devDependencies": {
44 "@changesets/cli": "^2.30.0",44 "@changesets/cli": "^2.30.0",
45 "@oxlint/plugins": "^1.57.0",
45 "knip": "^5.85.0",46 "knip": "^5.85.0",
46 "oxlint": "^1.56.0",47 "oxlint": "^1.56.0",
47 "turbo": "^2.5.6",48 "turbo": "^2.5.6",
packages/clients/react/src/plugins/components/ui/dialog.tsx+5−0
@@ -145,6 +145,10 @@ function DialogDescription({ · 144 unmodified lines
145 )145 )
146}146}
147 147
148function DialogPopup({ ...props }: DialogPrimitive.Popup.Props) {
149 return <DialogPrimitive.Popup data-slot="dialog-popup" {...props} />
150}
151
148export {152export {
149 Dialog,153 Dialog,
150 DialogClose,154 DialogClose,
@@ -153,6 +157,7 @@ export { · 2 unmodified lines
153 DialogFooter,157 DialogFooter,
154 DialogHeader,158 DialogHeader,
155 DialogOverlay,159 DialogOverlay,
160 DialogPopup,
156 DialogPortal,161 DialogPortal,
157 DialogTitle,162 DialogTitle,
158 DialogTrigger,163 DialogTrigger,
packages/clients/react/src/plugins/index.ts+1−0
@@ -123,3 +123,4 @@ export { Separator } from "./components/ui/separator"; · 122 unmodified lines
123export { Textarea } from "./components/ui/textarea";123export { Textarea } from "./components/ui/textarea";
124export { Popover, PopoverContent, PopoverTrigger } from "./components/ui/popover";124export { Popover, PopoverContent, PopoverTrigger } from "./components/ui/popover";
125export { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem, CommandShortcut, CommandSeparator } from "./components/ui/command";125export { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem, CommandShortcut, CommandSeparator } from "./components/ui/command";
126export { 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 @@
1import { useState, useMemo, useCallback } from "react";
1import type {2import type {
2 Execution,3 Execution,
3 ExecutionEnvelope,4 ExecutionEnvelope,
@@ -7,56 +8,51 @@ import { · 3 unmodified lines
7 useExecutions,8 useExecutions,
8} from "@executor/react";9} from "@executor/react";
9import {10import {
10 Alert,
11 Badge,
12 Button,11 Button,
13 Card,12 cn,
13 Dialog,
14 DialogOverlay,
15 DialogPopup,
16 DialogPortal,
14 DocumentPanel,17 DocumentPanel,
15 EmptyState,18 EmptyState,
19 IconClose,
20 Input,
16 LoadableBlock,21 LoadableBlock,
22 Select,
17 useExecutorPluginNavigation,23 useExecutorPluginNavigation,
18 useExecutorPluginRouteParams,24 useExecutorPluginRouteParams,
25 useExecutorPluginSearch,
19} from "@executor/react/plugins";26} from "@executor/react/plugins";
20 27
21const formatTimestamp = (value: number | null): string => {28// ── Helpers ──────────────────────────────────────────────────────────────
22 if (value === null) {29
23 return "—";30type ExecutionStatus = Execution["status"];
24 }
25 31
26 return new Intl.DateTimeFormat(undefined, {32const formatTimestamp = (value: number | null): string => {
27 dateStyle: "medium",33 if (value === null) return "—";
28 timeStyle: "short",34 const d = new Date(value);
29 }).format(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}`;
30};41};
31 42
32const formatDuration = (execution: Execution): string | null => {43const formatDuration = (execution: Execution): string | null => {
33 if (execution.startedAt === null || execution.completedAt === null) {44 if (execution.startedAt === null || execution.completedAt === null) return null;
34 return null;45 const ms = Math.max(0, execution.completedAt - execution.startedAt);
35 }46 if (ms < 1_000) return `${ms}ms`;
36 47 if (ms < 60_000) return `${(ms / 1_000).toFixed(1)}s`;
37 const durationMs = Math.max(0, execution.completedAt - execution.startedAt);48 return `${(ms / 60_000).toFixed(1)}m`;
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`;
47};49};
48 50
49const summarizeCode = (code: string): string =>51const truncateCode = (code: string, max = 80): string =>
50 code52 code.trim().replace(/\s+/g, " ").slice(0, max);
51 .trim()
52 .replace(/\s+/g, " ")
53 .slice(0, 220);
54 53
55const formatJsonDocument = (value: string | null): string | null => {54const formatJsonDocument = (value: string | null): string | null => {
56 if (value === null) {55 if (value === null) return null;
57 return null;
58 }
59
60 try {56 try {
61 return JSON.stringify(JSON.parse(value), null, 2);57 return JSON.stringify(JSON.parse(value), null, 2);
62 } catch {58 } catch {
@@ -64,136 +60,133 @@ const formatJsonDocument = (value: string | null): string | null => { · 1 unmodified line
64 }60 }
65};61};
66 62
67const statusVariant = (63const STATUS_STYLES = {
68 status: Execution["status"],64 completed: { dot: "bg-primary", text: "text-primary" },
69): "default" | "muted" | "outline" | "destructive" => {65 failed: { dot: "bg-destructive", text: "text-destructive" },
70 switch (status) {66 running: { dot: "bg-blue-400 animate-pulse", text: "text-blue-400" },
71 case "completed":67 waiting_for_interaction: { dot: "bg-amber-400 animate-pulse", text: "text-amber-400" },
72 return "default";68 pending: { dot: "bg-muted", text: "text-muted-foreground" },
73 case "failed":69 cancelled: { dot: "bg-muted", text: "text-muted-foreground" },
74 return "destructive";70} as const satisfies Record<ExecutionStatus, { dot: string; text: string }>;
75 case "running":71
76 case "waiting_for_interaction":72const statusDot = (status: ExecutionStatus): string => STATUS_STYLES[status].dot;
77 return "outline";73const statusText = (status: ExecutionStatus): string => STATUS_STYLES[status].text;
78 default:74
79 return "muted";75const STATUS_OPTIONS: Array<{ value: "all" | ExecutionStatus; label: string }> = [
80 }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
85const 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();
81};89};
82 90
83const MetadataItem = (props: {91// ── Execution Row ────────────────────────────────────────────────────────
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);
94 92
95const ExecutionCard = (props: {93const ExecutionRow = (props: {
96 execution: Execution;94 execution: Execution;
97 onOpen: () => void;95 isSelected: boolean;
96 onSelect: () => void;
98}) => {97}) => {
99 const { execution } = props;98 const { execution, isSelected } = props;
99 const durationMs = formatDurationMs(execution);
100 100
101 return (101 return (
102 <Button102 <button
103 variant="ghost"
104 type="button"103 type="button"
105 onClick={props.onOpen}104 onClick={props.onSelect}
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"105 className={cn(
107 >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]",
108 <div className="flex items-start justify-between gap-3">107 isSelected && "bg-white/[0.06]",
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 )}108 )}
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>
134 );132 );
135};133};
136 134
137export function ExecutionHistoryPage() {135// ── Detail Drawer ────────────────────────────────────────────────────────
138 const executions = useExecutions();
139 const navigation = useExecutorPluginNavigation();
140 136
141 return (137type DetailTab = "properties" | "logs";
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>
152 138
153 <LoadableBlock loadable={executions} loading="Loading executions...">139const DetailTabButton = (props: {
154 {(items) =>140 label: string;
155 items.length === 0 ? (141 active: boolean;
156 <div className="rounded-2xl border border-dashed border-border py-20">142 onClick: () => void;
157 <EmptyState143}) => (
158 title="No executions yet"144 <button
159 description="Run something and it will show up here."145 type="button"
160 />146 onClick={props.onClick}
161 </div>147 className={cn(
162 ) : (148 "px-3 py-1.5 text-sm font-medium transition-colors",
163 <div className="grid gap-3">149 props.active
164 {items.map((execution) => (150 ? "border-b-2 border-primary text-foreground"
165 <ExecutionCard151 : "text-muted-foreground hover:text-foreground",
166 key={execution.id}152 )}
167 execution={execution}153 >
168 onOpen={() => {154 {props.label}
169 void navigation.route(execution.id);155 </button>
170 }}156);
171 />
172 ))}
173 </div>
174 )
175 }
176 </LoadableBlock>
177 </div>
178 </div>
179 );
180}
181 157
182const ExecutionDetailSections = (props: {158const PropertiesTab = (props: { envelope: ExecutionEnvelope }) => {
183 envelope: ExecutionEnvelope;
184}) => {
185 const { execution, pendingInteraction } = props.envelope;159 const { execution, pendingInteraction } = props.envelope;
186 160
187 return (161 return (
188 <div className="space-y-6">162 <div className="space-y-4">
189 <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">163 <div className="grid grid-cols-2 gap-3">
190 <MetadataItem label="Status" value={execution.status.replaceAll("_", " ")} />164 <div className="rounded-lg border border-border/50 px-3 py-2">
191 <MetadataItem label="Created" value={formatTimestamp(execution.createdAt)} />165 <div className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/70">
192 <MetadataItem label="Started" value={formatTimestamp(execution.startedAt)} />166 Status
193 <MetadataItem167 </div>
194 label="Completed"168 <div className="mt-1 flex items-center gap-2">
195 value={formatTimestamp(execution.completedAt)}169 <span className={cn("size-2 rounded-full", statusDot(execution.status))} />
196 />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>
197 </div>190 </div>
198 191
199 <DocumentPanel192 <DocumentPanel
@@ -203,20 +196,12 @@ const ExecutionDetailSections = (props: { · 3 unmodified lines
203 empty="No code captured."196 empty="No code captured."
204 />197 />
205 198
206 <div className="grid gap-4 xl:grid-cols-2">199 <DocumentPanel
207 <DocumentPanel200 title="Result"
208 title="Result"201 body={formatJsonDocument(execution.resultJson)}
209 body={formatJsonDocument(execution.resultJson)}202 lang="json"
210 lang="json"203 empty="No result recorded."
211 empty="No result recorded."204 />
212 />
213 <DocumentPanel
214 title="Logs"
215 body={formatJsonDocument(execution.logsJson)}
216 lang="json"
217 empty="No logs recorded."
218 />
219 </div>
220 205
221 {execution.errorText && (206 {execution.errorText && (
222 <DocumentPanel207 <DocumentPanel
@@ -228,26 +213,14 @@ const ExecutionDetailSections = (props: { · 5 unmodified lines
228 )}213 )}
229 214
230 {pendingInteraction && (215 {pendingInteraction && (
231 <div className="space-y-4">216 <div className="space-y-3">
232 <div>217 <div className="text-sm font-medium text-foreground">
233 <h2 className="text-sm font-semibold text-foreground">218 Pending interaction
234 Pending interaction219 <span className="ml-2 text-xs text-muted-foreground">
235 </h2>220 {pendingInteraction.kind} — {pendingInteraction.purpose}
236 <p className="mt-1 text-sm text-muted-foreground">221 </span>
237 {pendingInteraction.kind} • {pendingInteraction.purpose}
238 </p>
239 </div>222 </div>
240 223 <div className="grid gap-3 xl:grid-cols-2">
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 <DocumentPanel224 <DocumentPanel
252 title="Request"225 title="Request"
253 body={formatJsonDocument(pendingInteraction.payloadJson)}226 body={formatJsonDocument(pendingInteraction.payloadJson)}
@@ -267,6 +240,331 @@ const ExecutionDetailSections = (props: { · 13 unmodified lines
267 );240 );
268};241};
269 242
243const 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
297const 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
401type ExecutionHistorySearch = {
402 executionId?: string;
403};
404
405export 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
542const 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
270export function ExecutionHistoryDetailPage() {568export function ExecutionHistoryDetailPage() {
271 const { executionId } = useExecutorPluginRouteParams<{569 const { executionId } = useExecutorPluginRouteParams<{
272 executionId: string;570 executionId: string;
@@ -276,29 +574,29 @@ export function ExecutionHistoryDetailPage() { · 3 unmodified lines
276 574
277 return (575 return (
278 <div className="h-full overflow-y-auto">576 <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">577 <div className="mx-auto max-w-5xl px-6 py-10 lg:px-10 lg:py-14">
280 <div className="mb-8 flex flex-wrap items-center justify-between gap-4">578 <div className="mb-8">
281 <div>579 <Button
282 <Button580 variant="ghost"
283 variant="ghost"581 type="button"
284 type="button"582 onClick={() => {
285 onClick={() => {583 void navigation.route();
286 void navigation.route();584 }}
287 }}585 className="mb-3 h-auto p-0 text-xs font-medium uppercase tracking-wider text-muted-foreground transition-colors hover:text-foreground"
288 className="mb-3 h-auto p-0 text-xs font-medium uppercase tracking-wider text-muted-foreground transition-colors hover:text-foreground"586 >
289 >587 Back to history
290 Back to history588 </Button>
291 </Button>589 <div className="flex items-center gap-3">
292 <div className="flex items-center gap-3">590 <h1 className="font-display text-2xl tracking-tight text-foreground lg:text-3xl">
293 <h1 className="font-display text-2xl tracking-tight text-foreground lg:text-3xl">591 {executionId}
294 {executionId}592 </h1>
295 </h1>
296 </div>
297 </div>593 </div>
298 </div>594 </div>
299 595
300 <LoadableBlock loadable={execution} loading="Loading execution...">596 <LoadableBlock loadable={execution} loading="Loading execution...">
301 {(envelope) => <ExecutionDetailSections envelope={envelope} />}597 {(envelope) => (
598 <ExecutionDetailInline envelope={envelope} />
599 )}
302 </LoadableBlock>600 </LoadableBlock>
303 </div>601 </div>
304 </div>602 </div>