diff --git a/apps/cli/src/main.ts b/apps/cli/src/main.ts
index 0192820eb..0c786464e 100644
--- a/apps/cli/src/main.ts
+++ b/apps/cli/src/main.ts
@@ -394,6 +394,7 @@ const executeCode = (input: {
payload: {
code: input.code,
},
+ headers: { "x-executor-trigger": "cli" },
});
if (response.status === "paused") {
diff --git a/apps/cloud/src/api/autumn.ts b/apps/cloud/src/api/autumn.ts
index 5275a1961..8dac53909 100644
--- a/apps/cloud/src/api/autumn.ts
+++ b/apps/cloud/src/api/autumn.ts
@@ -4,9 +4,31 @@ import { HttpServerRequest, HttpServerResponse } from "@effect/platform";
import { autumnHandler } from "autumn-js/backend";
import { WorkOSAuth } from "../auth/workos";
+import { AutumnService } from "../services/autumn";
import { HttpResponseError, isServerError, toErrorServerResponse } from "./error-response";
import { SharedServices } from "./layers";
+export const makeTrackExecutionUsage = (autumn: AutumnService["Type"]) => {
+ return (organizationId: string): void => {
+ autumn
+ .use((client) =>
+ client.track({
+ customerId: organizationId,
+ featureId: "executions",
+ value: 1,
+ }),
+ )
+ .pipe(
+ Effect.catchAll((err) =>
+ Effect.logError("[billing] track failed").pipe(
+ Effect.annotateLogs("error", String(err)),
+ ),
+ ),
+ Effect.runFork,
+ );
+ };
+};
+
export const AutumnApiApp = Effect.gen(function* () {
const request = yield* HttpServerRequest.HttpServerRequest;
const webRequest = yield* Effect.mapError(
@@ -66,7 +88,10 @@ export const AutumnApiApp = Effect.gen(function* () {
);
if (statusCode >= 400) {
- console.error("[autumn] upstream error:", statusCode, response);
+ yield* Effect.logWarning("[autumn] upstream error").pipe(
+ Effect.annotateLogs("statusCode", statusCode),
+ Effect.annotateLogs("response", JSON.stringify(response)),
+ );
return yield* Effect.fail(
new HttpResponseError({
status: statusCode,
@@ -81,7 +106,10 @@ export const AutumnApiApp = Effect.gen(function* () {
Effect.provide(SharedServices),
Effect.catchAll((err) => {
if (isServerError(err)) {
- console.error("[autumn] request failed:", err instanceof Error ? err.stack : err);
+ return Effect.logError("[autumn] request failed").pipe(
+ Effect.annotateLogs("error", err instanceof Error ? err.stack ?? err.message : String(err)),
+ Effect.map(() => toErrorServerResponse(err)),
+ );
}
return Effect.succeed(toErrorServerResponse(err));
}),
diff --git a/apps/cloud/src/api/protected.ts b/apps/cloud/src/api/protected.ts
index 3cd99a342..d4125fac5 100644
--- a/apps/cloud/src/api/protected.ts
+++ b/apps/cloud/src/api/protected.ts
@@ -81,7 +81,10 @@ export const ProtectedApiApp = Effect.gen(function* () {
Effect.provide(SharedServices),
Effect.catchAll((err) => {
if (isServerError(err)) {
- console.error("[api] request failed:", err instanceof Error ? err.stack : err);
+ return Effect.logError("[api] request failed").pipe(
+ Effect.annotateLogs("error", err instanceof Error ? err.stack ?? err.message : String(err)),
+ Effect.map(() => toErrorServerResponse(err)),
+ );
}
return Effect.succeed(toErrorServerResponse(err));
}),
diff --git a/apps/cloud/src/routeTree.gen.ts b/apps/cloud/src/routeTree.gen.ts
index 46104e23b..60dd41900 100644
--- a/apps/cloud/src/routeTree.gen.ts
+++ b/apps/cloud/src/routeTree.gen.ts
@@ -11,6 +11,7 @@
import { Route as rootRouteImport } from './routes/__root'
import { Route as ToolsRouteImport } from './routes/tools'
import { Route as SecretsRouteImport } from './routes/secrets'
+import { Route as RunsRouteImport } from './routes/runs'
import { Route as OrgRouteImport } from './routes/org'
import { Route as ConnectionsRouteImport } from './routes/connections'
import { Route as BillingRouteImport } from './routes/billing'
@@ -29,6 +30,11 @@ const SecretsRoute = SecretsRouteImport.update({
path: '/secrets',
getParentRoute: () => rootRouteImport,
} as any)
+const RunsRoute = RunsRouteImport.update({
+ id: '/runs',
+ path: '/runs',
+ getParentRoute: () => rootRouteImport,
+} as any)
const OrgRoute = OrgRouteImport.update({
id: '/org',
path: '/org',
@@ -70,6 +76,7 @@ export interface FileRoutesByFullPath {
'/billing': typeof BillingRoute
'/connections': typeof ConnectionsRoute
'/org': typeof OrgRoute
+ '/runs': typeof RunsRoute
'/secrets': typeof SecretsRoute
'/tools': typeof ToolsRoute
'/billing/plans': typeof BillingPlansRoute
@@ -81,6 +88,7 @@ export interface FileRoutesByTo {
'/billing': typeof BillingRoute
'/connections': typeof ConnectionsRoute
'/org': typeof OrgRoute
+ '/runs': typeof RunsRoute
'/secrets': typeof SecretsRoute
'/tools': typeof ToolsRoute
'/billing/plans': typeof BillingPlansRoute
@@ -93,6 +101,7 @@ export interface FileRoutesById {
'/billing': typeof BillingRoute
'/connections': typeof ConnectionsRoute
'/org': typeof OrgRoute
+ '/runs': typeof RunsRoute
'/secrets': typeof SecretsRoute
'/tools': typeof ToolsRoute
'/billing_/plans': typeof BillingPlansRoute
@@ -106,6 +115,7 @@ export interface FileRouteTypes {
| '/billing'
| '/connections'
| '/org'
+ | '/runs'
| '/secrets'
| '/tools'
| '/billing/plans'
@@ -117,6 +127,7 @@ export interface FileRouteTypes {
| '/billing'
| '/connections'
| '/org'
+ | '/runs'
| '/secrets'
| '/tools'
| '/billing/plans'
@@ -128,6 +139,7 @@ export interface FileRouteTypes {
| '/billing'
| '/connections'
| '/org'
+ | '/runs'
| '/secrets'
| '/tools'
| '/billing_/plans'
@@ -140,6 +152,7 @@ export interface RootRouteChildren {
BillingRoute: typeof BillingRoute
ConnectionsRoute: typeof ConnectionsRoute
OrgRoute: typeof OrgRoute
+ RunsRoute: typeof RunsRoute
SecretsRoute: typeof SecretsRoute
ToolsRoute: typeof ToolsRoute
BillingPlansRoute: typeof BillingPlansRoute
@@ -163,6 +176,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof SecretsRouteImport
parentRoute: typeof rootRouteImport
}
+ '/runs': {
+ id: '/runs'
+ path: '/runs'
+ fullPath: '/runs'
+ preLoaderRoute: typeof RunsRouteImport
+ parentRoute: typeof rootRouteImport
+ }
'/org': {
id: '/org'
path: '/org'
@@ -220,6 +240,7 @@ const rootRouteChildren: RootRouteChildren = {
BillingRoute: BillingRoute,
ConnectionsRoute: ConnectionsRoute,
OrgRoute: OrgRoute,
+ RunsRoute: RunsRoute,
SecretsRoute: SecretsRoute,
ToolsRoute: ToolsRoute,
BillingPlansRoute: BillingPlansRoute,
diff --git a/apps/cloud/src/routes/runs.tsx b/apps/cloud/src/routes/runs.tsx
new file mode 100644
index 000000000..175234c0e
--- /dev/null
+++ b/apps/cloud/src/routes/runs.tsx
@@ -0,0 +1,24 @@
+import { Schema } from "effect";
+import { createFileRoute } from "@tanstack/react-router";
+import { RunsPage, type RunsSearch } from "@executor/react/pages/runs";
+
+const RunsSearchSchema = Schema.standardSchemaV1(
+ Schema.Struct({
+ executionId: Schema.optional(Schema.String),
+ status: Schema.optional(Schema.String),
+ trigger: Schema.optional(Schema.String),
+ tool: Schema.optional(Schema.String),
+ range: Schema.optional(Schema.String),
+ from: Schema.optional(Schema.String),
+ to: Schema.optional(Schema.String),
+ code: Schema.optional(Schema.String),
+ live: Schema.optional(Schema.String),
+ sort: Schema.optional(Schema.String),
+ elicitation: Schema.optional(Schema.String),
+ }),
+);
+
+export const Route = createFileRoute("/runs")({
+ validateSearch: RunsSearchSchema,
+ component: () => ,
+});
diff --git a/apps/cloud/src/web/shell.tsx b/apps/cloud/src/web/shell.tsx
index 4aa725027..9d685ede3 100644
--- a/apps/cloud/src/web/shell.tsx
+++ b/apps/cloud/src/web/shell.tsx
@@ -360,6 +360,7 @@ function UserFooter() {
function SidebarContent(props: { pathname: string; onNavigate?: () => void; showBrand?: boolean }) {
const isHome = props.pathname === "/";
const isSecrets = props.pathname === "/secrets";
+ const isRuns = props.pathname === "/runs";
const isConnections = props.pathname === "/connections";
const isBilling = props.pathname === "/billing" || props.pathname.startsWith("/billing/");
const isOrg = props.pathname === "/org";
@@ -378,6 +379,7 @@ function SidebarContent(props: { pathname: string; onNavigate?: () => void; show
+
diff --git a/apps/local/src/routes/runs.tsx b/apps/local/src/routes/runs.tsx
new file mode 100644
index 000000000..175234c0e
--- /dev/null
+++ b/apps/local/src/routes/runs.tsx
@@ -0,0 +1,24 @@
+import { Schema } from "effect";
+import { createFileRoute } from "@tanstack/react-router";
+import { RunsPage, type RunsSearch } from "@executor/react/pages/runs";
+
+const RunsSearchSchema = Schema.standardSchemaV1(
+ Schema.Struct({
+ executionId: Schema.optional(Schema.String),
+ status: Schema.optional(Schema.String),
+ trigger: Schema.optional(Schema.String),
+ tool: Schema.optional(Schema.String),
+ range: Schema.optional(Schema.String),
+ from: Schema.optional(Schema.String),
+ to: Schema.optional(Schema.String),
+ code: Schema.optional(Schema.String),
+ live: Schema.optional(Schema.String),
+ sort: Schema.optional(Schema.String),
+ elicitation: Schema.optional(Schema.String),
+ }),
+);
+
+export const Route = createFileRoute("/runs")({
+ validateSearch: RunsSearchSchema,
+ component: () => ,
+});
diff --git a/apps/local/src/web/shell.tsx b/apps/local/src/web/shell.tsx
index 879210adb..b1baab69c 100644
--- a/apps/local/src/web/shell.tsx
+++ b/apps/local/src/web/shell.tsx
@@ -305,6 +305,7 @@ function SidebarContent(props: {
}) {
const isHome = props.pathname === "/";
const isSecrets = props.pathname === "/secrets";
+ const isRuns = props.pathname === "/runs";
const isConnections = props.pathname === "/connections";
return (
@@ -322,6 +323,7 @@ function SidebarContent(props: {
+
{/* Sources list */}
diff --git a/packages/core/sdk/src/cursor.ts b/packages/core/sdk/src/cursor.ts
new file mode 100644
index 000000000..64da2cad9
--- /dev/null
+++ b/packages/core/sdk/src/cursor.ts
@@ -0,0 +1,25 @@
+import type { Execution } from "./executions";
+import { ExecutionId } from "./ids";
+
+export const encodeCursor = (execution: Execution): string =>
+ encodeURIComponent(JSON.stringify({ createdAt: execution.createdAt, id: execution.id }));
+
+export const decodeCursor = (
+ cursor: string,
+): {
+ readonly createdAt: number;
+ readonly id: ExecutionId;
+} | null => {
+ try {
+ const parsed = JSON.parse(decodeURIComponent(cursor)) as {
+ createdAt?: unknown;
+ id?: unknown;
+ };
+ if (typeof parsed.createdAt !== "number" || typeof parsed.id !== "string") {
+ return null;
+ }
+ return { createdAt: parsed.createdAt, id: ExecutionId.make(parsed.id) };
+ } catch {
+ return null;
+ }
+};
diff --git a/packages/core/sdk/src/executions.ts b/packages/core/sdk/src/executions.ts
new file mode 100644
index 000000000..2de3d65ee
--- /dev/null
+++ b/packages/core/sdk/src/executions.ts
@@ -0,0 +1,404 @@
+import { Context, Effect, Schema } from "effect";
+
+import { ExecutionId, ExecutionInteractionId, ExecutionToolCallId, ScopeId } from "./ids";
+
+export const ExecutionStatus = Schema.Literal(
+ "pending",
+ "running",
+ "waiting_for_interaction",
+ "completed",
+ "failed",
+ "cancelled",
+);
+export type ExecutionStatus = typeof ExecutionStatus.Type;
+
+export class Execution extends Schema.Class
("Execution")({
+ id: ExecutionId,
+ scopeId: ScopeId,
+ status: ExecutionStatus,
+ code: Schema.String,
+ resultJson: Schema.NullOr(Schema.String),
+ errorText: Schema.NullOr(Schema.String),
+ logsJson: Schema.NullOr(Schema.String),
+ startedAt: Schema.NullOr(Schema.Number),
+ completedAt: Schema.NullOr(Schema.Number),
+ triggerKind: Schema.NullOr(Schema.String),
+ triggerMetaJson: Schema.NullOr(Schema.String),
+ toolCallCount: Schema.Number,
+ createdAt: Schema.Number,
+ updatedAt: Schema.Number,
+}) {}
+
+export const ExecutionInteractionStatus = Schema.Literal("pending", "resolved", "cancelled");
+export type ExecutionInteractionStatus = typeof ExecutionInteractionStatus.Type;
+
+export class ExecutionInteraction extends Schema.Class(
+ "ExecutionInteraction",
+)({
+ id: ExecutionInteractionId,
+ executionId: ExecutionId,
+ status: ExecutionInteractionStatus,
+ kind: Schema.String,
+ purpose: Schema.String,
+ payloadJson: Schema.String,
+ responseJson: Schema.NullOr(Schema.String),
+ responsePrivateJson: Schema.NullOr(Schema.String),
+ createdAt: Schema.Number,
+ updatedAt: Schema.Number,
+}) {}
+
+export const ExecutionToolCallStatus = Schema.Literal("running", "completed", "failed");
+export type ExecutionToolCallStatus = typeof ExecutionToolCallStatus.Type;
+
+export class ExecutionToolCall extends Schema.Class("ExecutionToolCall")({
+ id: ExecutionToolCallId,
+ executionId: ExecutionId,
+ status: ExecutionToolCallStatus,
+ /** Full dotted path, e.g. `"github.listIssues"`. */
+ toolPath: Schema.String,
+ /** First path segment, e.g. `"github"`. Useful for facet grouping. */
+ namespace: Schema.String,
+ argsJson: Schema.NullOr(Schema.String),
+ resultJson: Schema.NullOr(Schema.String),
+ errorText: Schema.NullOr(Schema.String),
+ startedAt: Schema.Number,
+ completedAt: Schema.NullOr(Schema.Number),
+ durationMs: Schema.NullOr(Schema.Number),
+}) {}
+
+export type CreateExecutionInput = Omit;
+export type UpdateExecutionInput = Partial<
+ Pick<
+ Execution,
+ | "status"
+ | "code"
+ | "resultJson"
+ | "errorText"
+ | "logsJson"
+ | "startedAt"
+ | "completedAt"
+ | "toolCallCount"
+ | "updatedAt"
+ >
+>;
+
+export type CreateExecutionInteractionInput = Omit;
+export type UpdateExecutionInteractionInput = Partial<
+ Pick<
+ ExecutionInteraction,
+ | "status"
+ | "kind"
+ | "purpose"
+ | "payloadJson"
+ | "responseJson"
+ | "responsePrivateJson"
+ | "updatedAt"
+ >
+>;
+
+export type CreateExecutionToolCallInput = Omit;
+export type UpdateExecutionToolCallInput = Partial<
+ Pick
+>;
+
+export type ExecutionSortField = "createdAt" | "durationMs";
+export type ExecutionSortDirection = "asc" | "desc";
+export interface ExecutionSort {
+ readonly field: ExecutionSortField;
+ readonly direction: ExecutionSortDirection;
+}
+
+export interface ExecutionListOptions {
+ readonly limit: number;
+ readonly cursor?: string;
+ readonly statusFilter?: readonly ExecutionStatus[];
+ readonly triggerFilter?: readonly string[];
+ /** Glob patterns: exact match or trailing wildcard (`a.*`). */
+ readonly toolPathFilter?: readonly string[];
+ readonly timeRange?: {
+ readonly from?: number;
+ readonly to?: number;
+ };
+ readonly codeQuery?: string;
+ /** Filter by whether the run recorded any interaction. */
+ readonly hadElicitation?: boolean;
+ /** Return only rows created after this timestamp. */
+ readonly after?: number;
+ /** Sort order. Defaults to createdAt desc. */
+ readonly sort?: ExecutionSort;
+ /** When true, compute and return ExecutionListMeta. */
+ readonly includeMeta?: boolean;
+}
+
+export type ExecutionListItem = Execution & {
+ readonly pendingInteraction: ExecutionInteraction | null;
+};
+
+/**
+ * One bucket in the execution timeline chart. `timestamp` is the bucket
+ * start in epoch-ms. The remaining keys are counts per status.
+ */
+export interface ExecutionChartBucket {
+ readonly timestamp: number;
+ readonly pending: number;
+ readonly running: number;
+ readonly waiting_for_interaction: number;
+ readonly completed: number;
+ readonly failed: number;
+ readonly cancelled: number;
+}
+
+export interface ExecutionToolFacet {
+ readonly toolPath: string;
+ readonly count: number;
+}
+
+export interface ExecutionListMeta {
+ readonly totalRowCount: number;
+ readonly filterRowCount: number;
+ readonly chartBucketMs: number;
+ readonly chartData: readonly ExecutionChartBucket[];
+ readonly statusCounts: Readonly>;
+ /** Count of executions per `triggerKind`. Includes `"unknown"` for nulls. */
+ readonly triggerCounts: Readonly>;
+ /** Top-N tool paths by invocation count across the filtered set. */
+ readonly toolFacets: readonly ExecutionToolFacet[];
+ readonly interactionCounts: {
+ readonly withElicitation: number;
+ readonly withoutElicitation: number;
+ };
+}
+
+export const EXECUTION_STATUS_KEYS = [
+ "pending",
+ "running",
+ "waiting_for_interaction",
+ "completed",
+ "failed",
+ "cancelled",
+] as const satisfies readonly ExecutionStatus[];
+
+export const pickChartBucketMs = (spanMs: number): number => {
+ const MIN = 60_000;
+ const HOUR = 60 * MIN;
+ const DAY = 24 * HOUR;
+
+ if (spanMs <= 10 * MIN) return MIN; // 1 minute
+ if (spanMs <= DAY) return 5 * MIN; // 5 minutes
+ if (spanMs <= 7 * DAY) return HOUR; // 1 hour
+ if (spanMs <= 30 * DAY) return 6 * HOUR; // 6 hours
+ return DAY; // 1 day
+};
+
+type MutableBucket = {
+ -readonly [K in keyof ExecutionChartBucket]: ExecutionChartBucket[K];
+};
+
+const emptyBucket = (timestamp: number): MutableBucket => ({
+ timestamp,
+ pending: 0,
+ running: 0,
+ waiting_for_interaction: 0,
+ completed: 0,
+ failed: 0,
+ cancelled: 0,
+});
+
+export interface BuildExecutionListMetaInput {
+ readonly filtered: readonly Execution[];
+ readonly timeRange: ExecutionListOptions["timeRange"];
+ readonly totalRowCount: number;
+ /** Tool path invocation counts for populating toolFacets. */
+ readonly toolPathCounts?: ReadonlyMap;
+ /** Execution IDs with at least one interaction, for computing interactionCounts. */
+ readonly executionIdsWithInteractions?: ReadonlySet;
+}
+
+const TRIGGER_KIND_UNKNOWN = "unknown";
+
+export const buildExecutionListMeta = (input: BuildExecutionListMetaInput): ExecutionListMeta => {
+ const { filtered, timeRange, totalRowCount, toolPathCounts, executionIdsWithInteractions } =
+ input;
+ const filterRowCount = filtered.length;
+
+ const statusCounts: Record = {
+ pending: 0,
+ running: 0,
+ waiting_for_interaction: 0,
+ completed: 0,
+ failed: 0,
+ cancelled: 0,
+ };
+ const triggerCounts: Record = {};
+ let withElicitation = 0;
+ for (const execution of filtered) {
+ statusCounts[execution.status] += 1;
+ const key = execution.triggerKind ?? TRIGGER_KIND_UNKNOWN;
+ triggerCounts[key] = (triggerCounts[key] ?? 0) + 1;
+ if (executionIdsWithInteractions?.has(execution.id)) {
+ withElicitation += 1;
+ }
+ }
+ const interactionCounts = {
+ withElicitation,
+ withoutElicitation: filterRowCount - withElicitation,
+ };
+
+ const toolFacets: ExecutionToolFacet[] = toolPathCounts
+ ? [...toolPathCounts.entries()]
+ .map(([toolPath, count]) => ({ toolPath, count }))
+ .sort((a, b) => b.count - a.count)
+ .slice(0, 20)
+ : [];
+
+ if (filterRowCount === 0) {
+ return {
+ totalRowCount,
+ filterRowCount,
+ chartBucketMs: pickChartBucketMs(0),
+ chartData: [],
+ statusCounts,
+ triggerCounts,
+ toolFacets,
+ interactionCounts,
+ };
+ }
+
+ let minTs = Number.POSITIVE_INFINITY;
+ let maxTs = Number.NEGATIVE_INFINITY;
+ for (const execution of filtered) {
+ if (execution.createdAt < minTs) minTs = execution.createdAt;
+ if (execution.createdAt > maxTs) maxTs = execution.createdAt;
+ }
+
+ const rangeStart = timeRange?.from ?? minTs;
+ const rangeEnd = timeRange?.to ?? maxTs;
+ const span = Math.max(rangeEnd - rangeStart, 0);
+ const bucketMs = pickChartBucketMs(span);
+
+ const firstBucket = Math.floor(rangeStart / bucketMs) * bucketMs;
+ const lastBucket = Math.floor(rangeEnd / bucketMs) * bucketMs;
+
+ const bucketCount = Math.max(1, Math.floor((lastBucket - firstBucket) / bucketMs) + 1);
+ // Cap buckets so a misconfigured time range doesn't blow up the response.
+ const safeBucketCount = Math.min(bucketCount, 500);
+ const bucketMap = new Map();
+ for (let i = 0; i < safeBucketCount; i += 1) {
+ const ts = firstBucket + i * bucketMs;
+ bucketMap.set(ts, emptyBucket(ts));
+ }
+
+ for (const execution of filtered) {
+ const bucketStart = Math.floor(execution.createdAt / bucketMs) * bucketMs;
+ let bucket = bucketMap.get(bucketStart);
+ if (!bucket) {
+ bucket = emptyBucket(bucketStart);
+ bucketMap.set(bucketStart, bucket);
+ }
+ bucket[execution.status] += 1;
+ }
+
+ const chartData = [...bucketMap.values()].sort((a, b) => a.timestamp - b.timestamp);
+
+ return {
+ totalRowCount,
+ filterRowCount,
+ chartBucketMs: bucketMs,
+ chartData,
+ statusCounts,
+ triggerCounts,
+ toolFacets,
+ interactionCounts,
+ };
+};
+
+/**
+ * Match a tool path against a filter pattern. Supports exact match
+ * and trailing glob (`github.*` matches any `github.`).
+ */
+export const matchToolPathPattern = (toolPath: string, pattern: string): boolean => {
+ if (pattern === toolPath) return true;
+ if (pattern.endsWith(".*")) {
+ const prefix = pattern.slice(0, -1); // keep trailing `.`
+ return toolPath.startsWith(prefix);
+ }
+ return false;
+};
+
+const computeDuration = (execution: Execution): number | null => {
+ if (execution.startedAt === null || execution.completedAt === null) return null;
+ return Math.max(0, execution.completedAt - execution.startedAt);
+};
+
+/**
+ * Comparator suitable for `Array.prototype.sort`. Returns `< 0` when
+ * `a` should come before `b`. Tie-breaks on `id` DESC for stable
+ * ordering. Rows with `null` duration sort to the end regardless of
+ * direction.
+ */
+export const pickExecutionSorter = (
+ sort: ExecutionSort | undefined,
+): ((a: Execution, b: Execution) => number) => {
+ if (!sort || sort.field === "createdAt") {
+ const ascending = sort?.direction === "asc";
+ return (a, b) => {
+ const delta = a.createdAt - b.createdAt;
+ if (delta !== 0) return ascending ? delta : -delta;
+ return b.id.localeCompare(a.id);
+ };
+ }
+
+ // durationMs
+ const ascending = sort.direction === "asc";
+ return (a, b) => {
+ const da = computeDuration(a);
+ const db = computeDuration(b);
+ if (da === null && db === null) return b.id.localeCompare(a.id);
+ if (da === null) return 1;
+ if (db === null) return -1;
+ const delta = da - db;
+ if (delta !== 0) return ascending ? delta : -delta;
+ return b.id.localeCompare(a.id);
+ };
+};
+
+export class ExecutionStore extends Context.Tag("@executor/sdk/ExecutionStore")<
+ ExecutionStore,
+ {
+ readonly create: (input: CreateExecutionInput) => Effect.Effect;
+ readonly update: (id: ExecutionId, patch: UpdateExecutionInput) => Effect.Effect;
+ readonly list: (
+ scopeId: ScopeId,
+ options: ExecutionListOptions,
+ ) => Effect.Effect<{
+ readonly executions: readonly ExecutionListItem[];
+ readonly nextCursor?: string;
+ readonly meta?: ExecutionListMeta;
+ }>;
+ readonly get: (id: ExecutionId) => Effect.Effect<{
+ readonly execution: Execution;
+ readonly pendingInteraction: ExecutionInteraction | null;
+ } | null>;
+ readonly recordInteraction: (
+ executionId: ExecutionId,
+ interaction: CreateExecutionInteractionInput,
+ ) => Effect.Effect;
+ readonly resolveInteraction: (
+ interactionId: ExecutionInteractionId,
+ patch: UpdateExecutionInteractionInput,
+ ) => Effect.Effect;
+ /** Record the start of a tool call; returns the created row. */
+ readonly recordToolCall: (
+ input: CreateExecutionToolCallInput,
+ ) => Effect.Effect;
+ readonly finishToolCall: (
+ id: ExecutionToolCallId,
+ patch: UpdateExecutionToolCallInput,
+ ) => Effect.Effect;
+ /** List tool calls for an execution, ordered by start time. */
+ readonly listToolCalls: (
+ executionId: ExecutionId,
+ ) => Effect.Effect;
+ readonly sweep: () => Effect.Effect;
+ }
+>() {}
diff --git a/packages/core/sdk/src/ids.ts b/packages/core/sdk/src/ids.ts
index b62f0b395..5a3a108de 100644
--- a/packages/core/sdk/src/ids.ts
+++ b/packages/core/sdk/src/ids.ts
@@ -14,3 +14,12 @@ export type PolicyId = typeof PolicyId.Type;
export const ConnectionId = Schema.String.pipe(Schema.brand("ConnectionId"));
export type ConnectionId = typeof ConnectionId.Type;
+
+export const ExecutionId = Schema.String.pipe(Schema.brand("ExecutionId"));
+export type ExecutionId = typeof ExecutionId.Type;
+
+export const ExecutionInteractionId = Schema.String.pipe(Schema.brand("ExecutionInteractionId"));
+export type ExecutionInteractionId = typeof ExecutionInteractionId.Type;
+
+export const ExecutionToolCallId = Schema.String.pipe(Schema.brand("ExecutionToolCallId"));
+export type ExecutionToolCallId = typeof ExecutionToolCallId.Type;
diff --git a/packages/core/sdk/src/index.ts b/packages/core/sdk/src/index.ts
index d3180a8e6..011d6aa65 100644
--- a/packages/core/sdk/src/index.ts
+++ b/packages/core/sdk/src/index.ts
@@ -23,7 +23,16 @@ export { typedAdapter } from "@executor/storage-core";
export { StorageError, UniqueViolationError } from "@executor/storage-core";
// IDs (branded)
-export { ScopeId, ToolId, SecretId, PolicyId, ConnectionId } from "./ids";
+export {
+ ScopeId,
+ ToolId,
+ SecretId,
+ PolicyId,
+ ConnectionId,
+ ExecutionId,
+ ExecutionInteractionId,
+ ExecutionToolCallId,
+} from "./ids";
// Scope
export { Scope } from "./scope";
diff --git a/packages/hosts/mcp/src/server.ts b/packages/hosts/mcp/src/server.ts
index 4c30abbb5..894ec9cc0 100644
--- a/packages/hosts/mcp/src/server.ts
+++ b/packages/hosts/mcp/src/server.ts
@@ -297,10 +297,13 @@ export const createExecutorMcpServer = (
if (supportsManagedElicitation(server)) {
const result = yield* engine.execute(code, {
onElicitation: makeMcpElicitationHandler(server, debugLog),
+ trigger: { kind: "mcp" },
});
return toMcpResult(formatExecuteResult(result));
}
- const outcome = yield* engine.executeWithPause(code);
+ const outcome = yield* engine.executeWithPause(code, {
+ trigger: { kind: "mcp" },
+ });
debugLog("execute.paused_flow_result", {
status: outcome.status,
executionId: outcome.status === "paused" ? outcome.execution.id : undefined,
diff --git a/packages/kernel/core/src/effect-errors.ts b/packages/kernel/core/src/effect-errors.ts
index 26b5d7f8d..5e902761e 100644
--- a/packages/kernel/core/src/effect-errors.ts
+++ b/packages/kernel/core/src/effect-errors.ts
@@ -1,3 +1,4 @@
+import * as Cause from "effect/Cause";
import * as Data from "effect/Data";
export class KernelCoreEffectError extends Data.TaggedError("KernelCoreEffectError")<{
@@ -21,3 +22,45 @@ export class CodeExecutionError extends Data.TaggedError("CodeExecutionError")<{
readonly message: string;
readonly cause?: unknown;
}> {}
+
+/**
+ * Extract a human-readable message from an unknown error value.
+ * Handles Error instances, strings, objects with `.message`, and
+ * arbitrary values via JSON.stringify / String fallback.
+ */
+export const formatUnknownMessage = (cause: unknown): string => {
+ if (cause instanceof Error) {
+ const message = cause.message.trim();
+ return message.length > 0 ? message : cause.name;
+ }
+
+ if (typeof cause === "string") {
+ return cause;
+ }
+
+ if (
+ typeof cause === "object" &&
+ cause !== null &&
+ "message" in cause &&
+ typeof cause.message === "string"
+ ) {
+ const message = cause.message.trim();
+ if (message.length > 0) return message;
+ }
+
+ if (typeof cause === "object" && cause !== null) {
+ try {
+ return JSON.stringify(cause);
+ } catch {
+ return String(cause);
+ }
+ }
+
+ return String(cause);
+};
+
+/**
+ * Squash an Effect `Cause` and extract a readable message.
+ */
+export const formatCauseMessage = (cause: Cause.Cause): string =>
+ formatUnknownMessage(Cause.squash(cause));
diff --git a/packages/kernel/runtime-deno-subprocess/src/index.ts b/packages/kernel/runtime-deno-subprocess/src/index.ts
index c646772ba..1da7a1611 100644
--- a/packages/kernel/runtime-deno-subprocess/src/index.ts
+++ b/packages/kernel/runtime-deno-subprocess/src/index.ts
@@ -313,6 +313,7 @@ const executeInDeno = (
Effect.catchTag("DenoSpawnError", (e) =>
Effect.succeed({ result: null, error: e.message }),
),
+ Effect.withSpan("executor.runtime.deno"),
);
};
diff --git a/packages/kernel/runtime-secure-exec/src/index.ts b/packages/kernel/runtime-secure-exec/src/index.ts
index e690c09a7..b40529fba 100644
--- a/packages/kernel/runtime-secure-exec/src/index.ts
+++ b/packages/kernel/runtime-secure-exec/src/index.ts
@@ -3,8 +3,9 @@ import {
type CodeExecutor,
type ExecuteResult,
type SandboxToolInvoker,
+ formatUnknownMessage,
+ formatCauseMessage,
} from "@executor/codemode-core";
-import * as Cause from "effect/Cause";
import * as Data from "effect/Data";
import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
@@ -16,40 +17,6 @@ export type SecureExecExecutorOptions = {
memoryLimitMb?: number;
};
-const formatUnknownMessage = (cause: unknown): string => {
- if (cause instanceof Error) {
- const message = cause.message.trim();
- return message.length > 0 ? message : cause.name;
- }
-
- if (typeof cause === "string") {
- return cause;
- }
-
- if (
- typeof cause === "object" &&
- cause !== null &&
- "message" in cause &&
- typeof cause.message === "string"
- ) {
- const message = cause.message.trim();
- if (message.length > 0) return message;
- }
-
- if (typeof cause === "object" && cause !== null) {
- try {
- return JSON.stringify(cause);
- } catch {
- return String(cause);
- }
- }
-
- return String(cause);
-};
-
-const formatCauseMessage = (cause: Cause.Cause): string =>
- formatUnknownMessage(Cause.squash(cause));
-
class SecureExecExecutionError extends Data.TaggedError("SecureExecExecutionError")<{
readonly operation: string;
readonly cause: unknown;
@@ -438,6 +405,7 @@ const evaluateInSecureExec = (
logs,
}),
),
+ Effect.withSpan("executor.runtime.secure_exec"),
);
};
diff --git a/packages/react/package.json b/packages/react/package.json
index 557aa93cd..5dc591e76 100644
--- a/packages/react/package.json
+++ b/packages/react/package.json
@@ -18,6 +18,7 @@
},
"dependencies": {
"@base-ui/react": "^1.3.0",
+ "@date-fns/utc": "^2.1.0",
"@effect-atom/atom": "^0.5.0",
"@effect-atom/atom-react": "^0.5.0",
"@effect/platform": "catalog:",
@@ -27,10 +28,12 @@
"@lobehub/icons": "^5.4.0",
"@shikijs/langs": "^4.0.2",
"@shikijs/themes": "^4.0.2",
+ "@tanstack/react-query": "^5.62.12",
"@tanstack/react-router": "catalog:",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
+ "date-fns": "^3.6.0",
"effect": "catalog:",
"embla-carousel-react": "^8.6.0",
"input-otp": "^1.4.2",
@@ -39,6 +42,7 @@
"react": "catalog:",
"react-day-picker": "^9.14.0",
"react-hook-form": "^7.72.0",
+ "react-hotkeys-hook": "^5.2.4",
"react-resizable-panels": "^4",
"recharts": "3.8.0",
"shiki": "^4.0.2",
diff --git a/packages/react/src/api/executions.tsx b/packages/react/src/api/executions.tsx
new file mode 100644
index 000000000..53b6f4c31
--- /dev/null
+++ b/packages/react/src/api/executions.tsx
@@ -0,0 +1,115 @@
+import { endOfDay, parseISO, startOfDay } from "date-fns";
+import type {
+ Execution,
+ ExecutionChartBucket,
+ ExecutionInteraction,
+ ExecutionListMeta,
+ ExecutionToolCall,
+} from "@executor/sdk";
+
+import { getBaseUrl } from "./base-url";
+
+export type ExecutionListItem = Execution & {
+ readonly pendingInteraction: ExecutionInteraction | null;
+};
+
+export type ListExecutionsResponse = {
+ readonly executions: readonly ExecutionListItem[];
+ readonly nextCursor?: string;
+ readonly meta?: ExecutionListMeta;
+};
+
+export type { ExecutionChartBucket, ExecutionListMeta, ExecutionToolCall };
+
+export type GetExecutionResponse = {
+ readonly execution: Execution;
+ readonly pendingInteraction: ExecutionInteraction | null;
+};
+
+export type ListToolCallsResponse = {
+ readonly toolCalls: readonly ExecutionToolCall[];
+};
+
+export type RunsQueryInput = {
+ readonly limit: number;
+ readonly cursor?: string;
+ readonly status?: string;
+ readonly trigger?: string;
+ readonly tool?: string;
+ readonly from?: string;
+ readonly to?: string;
+ /** Live-mode floor: epoch-ms. Rows strictly newer than this. */
+ readonly after?: string;
+ readonly code?: string;
+ /** Sort expression `","` e.g. `"createdAt,desc"`. */
+ readonly sort?: string;
+ /**
+ * Interactions filter: `"true"` → only runs that recorded an
+ * elicitation, `"false"` → only runs that didn't, omitted → no
+ * filter. Maps to `hadElicitation` on the server side.
+ */
+ readonly elicitation?: string;
+};
+
+const toEpochRange = (date: string | undefined, mode: "start" | "end"): number | undefined => {
+ if (!date) return undefined;
+
+ try {
+ const parsed = parseISO(date);
+ return mode === "start" ? startOfDay(parsed).getTime() : endOfDay(parsed).getTime();
+ } catch {
+ return undefined;
+ }
+};
+
+const readJson = async (response: Response): Promise => {
+ if (!response.ok) {
+ const body = await response.text().catch(() => "");
+ throw new Error(body || `Request failed with status ${response.status}`);
+ }
+
+ return (await response.json()) as T;
+};
+
+export const listExecutions = async (input: RunsQueryInput): Promise => {
+ const params = new URLSearchParams();
+ params.set("limit", String(input.limit));
+
+ if (input.cursor) params.set("cursor", input.cursor);
+ if (input.status) params.set("status", input.status);
+ if (input.trigger) params.set("trigger", input.trigger);
+ if (input.tool) params.set("tool", input.tool);
+ if (input.after) params.set("after", input.after);
+ if (input.sort) params.set("sort", input.sort);
+ if (input.elicitation) params.set("elicitation", input.elicitation);
+
+ const from = toEpochRange(input.from, "start");
+ const to = toEpochRange(input.to, "end");
+ if (from !== undefined) params.set("from", String(from));
+ if (to !== undefined) params.set("to", String(to));
+ if (input.code?.trim()) params.set("code", input.code.trim());
+
+ const response = await fetch(`${getBaseUrl()}/executions?${params.toString()}`, {
+ credentials: "include",
+ });
+
+ return readJson(response);
+};
+
+export const getExecution = async (executionId: string): Promise => {
+ const response = await fetch(`${getBaseUrl()}/executions/${executionId}`, {
+ credentials: "include",
+ });
+
+ return readJson(response);
+};
+
+export const listExecutionToolCalls = async (
+ executionId: string,
+): Promise => {
+ const response = await fetch(`${getBaseUrl()}/executions/${executionId}/tool-calls`, {
+ credentials: "include",
+ });
+
+ return readJson(response);
+};
diff --git a/packages/react/src/api/provider.tsx b/packages/react/src/api/provider.tsx
index 968c2608e..52bb940ae 100644
--- a/packages/react/src/api/provider.tsx
+++ b/packages/react/src/api/provider.tsx
@@ -1,11 +1,23 @@
import { RegistryProvider } from "@effect-atom/atom-react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import * as React from "react";
import { ScopeProvider } from "./scope-context";
+const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ retry: 1,
+ refetchOnWindowFocus: true,
+ },
+ },
+});
+
export const ExecutorProvider = (
props: React.PropsWithChildren<{ fallback?: React.ReactNode }>,
) => (
-
- {props.children}
-
+
+
+ {props.children}
+
+
);
diff --git a/packages/react/src/components/command-palette.tsx b/packages/react/src/components/command-palette.tsx
index 6ef6e9618..5a5fecb64 100644
--- a/packages/react/src/components/command-palette.tsx
+++ b/packages/react/src/components/command-palette.tsx
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useNavigate } from "@tanstack/react-router";
import { useAtomValue, Result } from "@effect-atom/atom-react";
-import { PlusIcon } from "lucide-react";
+import { HistoryIcon, PlusIcon } from "lucide-react";
import { SourceFavicon } from "./source-favicon";
import { sourcesAtom } from "../api/atoms";
import { useScope } from "../hooks/use-scope";
@@ -116,6 +116,11 @@ export function CommandPalette(props: { sourcePlugins: readonly SourcePlugin[] }
[close, navigate],
);
+ const goToRuns = useCallback(() => {
+ close();
+ void navigate({ to: "/runs" });
+ }, [close, navigate]);
+
const goToPreset = useCallback(
(pluginKey: string, presetId: string, presetUrl?: string) => {
close();
@@ -152,21 +157,32 @@ export function CommandPalette(props: { sourcePlugins: readonly SourcePlugin[] }
)}
- {connectedSources.length > 0 && sourcePlugins.length > 0 && }
+ {(connectedSources.length > 0 || sourcePlugins.length > 0) && }
+
+
+
+
+ Runs
+ history
+
+
{sourcePlugins.length > 0 && (
-
- {sourcePlugins.map((plugin) => (
- goToAdd(plugin.key)}
- >
-
- Add {plugin.label}
-
- ))}
-
+ <>
+
+
+ {sourcePlugins.map((plugin) => (
+ goToAdd(plugin.key)}
+ >
+
+ Add {plugin.label}
+
+ ))}
+
+ >
)}
{presetEntries.length > 0 && }
diff --git a/packages/react/src/components/runs/column-header.tsx b/packages/react/src/components/runs/column-header.tsx
new file mode 100644
index 000000000..de12f3f77
--- /dev/null
+++ b/packages/react/src/components/runs/column-header.tsx
@@ -0,0 +1,99 @@
+import * as React from "react";
+import { ArrowDown, ArrowUp, ArrowUpDown } from "lucide-react";
+
+import { cn } from "../../lib/utils";
+
+export type SortField = "createdAt" | "durationMs";
+export type SortDirection = "asc" | "desc";
+export type SortState = {
+ readonly field: SortField;
+ readonly direction: SortDirection;
+} | null;
+
+export interface RunsColumnHeaderProps {
+ readonly sort: SortState;
+ readonly onSort: (field: SortField) => void;
+ readonly visibleFields?: {
+ readonly via?: boolean;
+ readonly tools?: boolean;
+ readonly log?: boolean;
+ readonly duration_ms?: boolean;
+ };
+}
+
+export function RunsColumnHeader({ sort, onSort, visibleFields }: RunsColumnHeaderProps) {
+ const showVia = visibleFields?.via !== false;
+ const showTools = visibleFields?.tools !== false;
+ const showLog = visibleFields?.log !== false;
+ const showDuration = visibleFields?.duration_ms !== false;
+
+ return (
+
+ {/* dot column (spacer to match row layout) */}
+
+
+
+
+ status
+
+ {showVia ? via : null}
+
+ {showTools ? tools : null}
+
+ {showLog ? log : null}
+
+ {showDuration ? (
+
+ ) : null}
+
+ code
+
+ );
+}
+
+function SortHeader({
+ label,
+ field,
+ currentSort,
+ onSort,
+ className,
+}: {
+ readonly label: string;
+ readonly field: SortField;
+ readonly currentSort: SortState;
+ readonly onSort: (field: SortField) => void;
+ readonly className?: string;
+}) {
+ const isActive = currentSort?.field === field;
+ const direction = isActive ? currentSort.direction : null;
+ const Icon = direction === "desc" ? ArrowDown : direction === "asc" ? ArrowUp : ArrowUpDown;
+
+ return (
+
+ );
+}
diff --git a/packages/react/src/components/runs/detail-drawer.tsx b/packages/react/src/components/runs/detail-drawer.tsx
new file mode 100644
index 000000000..a6250c293
--- /dev/null
+++ b/packages/react/src/components/runs/detail-drawer.tsx
@@ -0,0 +1,606 @@
+"use client";
+
+import * as React from "react";
+import { useQuery } from "@tanstack/react-query";
+import { useHotkeys } from "react-hotkeys-hook";
+import { ChevronDown, ChevronUp } from "lucide-react";
+import type { Execution, ExecutionInteraction, ExecutionToolCall } from "@executor/sdk";
+
+import { cn } from "../../lib/utils";
+import { Button } from "../button";
+import { CodeBlock } from "../code-block";
+import { HoverCardTimestamp } from "./hover-card-timestamp";
+import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "../sheet";
+import {
+ getExecution,
+ listExecutionToolCalls,
+ type GetExecutionResponse,
+} from "../../api/executions";
+import { STATUS_LABELS, statusTone, triggerTone } from "./status";
+
+type DetailTab = "properties" | "logs" | "toolCalls";
+
+const formatDuration = (execution: Execution): string => {
+ if (execution.startedAt === null || execution.completedAt === null) return "—";
+ const ms = Math.max(0, execution.completedAt - execution.startedAt);
+ if (ms < 1_000) return `${ms}ms`;
+ if (ms < 60_000) return `${(ms / 1_000).toFixed(1)}s`;
+ return `${(ms / 60_000).toFixed(1)}m`;
+};
+
+/** Recursively parse up to 4 layers of JSON-in-JSON — QuickJS double-serializes tool results. */
+const unwrapJson = (
+ raw: string | null,
+): { readonly formatted: string | null; readonly lang: "json" | "text" } => {
+ if (raw === null) return { formatted: null, lang: "json" };
+
+ let value: unknown = raw;
+ for (let i = 0; i < 4; i += 1) {
+ if (typeof value !== "string") break;
+ try {
+ value = JSON.parse(value);
+ } catch {
+ break;
+ }
+ }
+
+ if (typeof value === "string") {
+ return { formatted: value, lang: "text" };
+ }
+ try {
+ return { formatted: JSON.stringify(value, null, 2), lang: "json" };
+ } catch {
+ return { formatted: String(value), lang: "text" };
+ }
+};
+
+export interface RunsDetailDrawerProps {
+ readonly executionId?: string;
+ readonly onOpenChange: (open: boolean) => void;
+ /** Id of the previous row in the current filter set, or undefined if none. */
+ readonly prevRowId?: string;
+ /** Id of the next row in the current filter set, or undefined if none. */
+ readonly nextRowId?: string;
+ readonly onPrev?: () => void;
+ readonly onNext?: () => void;
+}
+
+export function RunsDetailDrawer({
+ executionId,
+ onOpenChange,
+ prevRowId,
+ nextRowId,
+ onPrev,
+ onNext,
+}: RunsDetailDrawerProps) {
+ const open = Boolean(executionId);
+ const query = useQuery({
+ queryKey: ["execution", executionId],
+ queryFn: () => getExecution(executionId!),
+ enabled: open,
+ staleTime: 10_000,
+ });
+
+ useHotkeys("ArrowUp", () => onPrev?.(), { enabled: open && !!prevRowId, preventDefault: true }, [
+ open,
+ prevRowId,
+ onPrev,
+ ]);
+ useHotkeys(
+ "ArrowDown",
+ () => onNext?.(),
+ { enabled: open && !!nextRowId, preventDefault: true },
+ [open, nextRowId, onNext],
+ );
+
+ return (
+
+
+ onOpenChange(false)}
+ prevRowId={prevRowId}
+ nextRowId={nextRowId}
+ onPrev={onPrev}
+ onNext={onNext}
+ />
+
+
+ );
+}
+
+function DrawerBody({
+ executionId,
+ query,
+ onClose,
+ prevRowId,
+ nextRowId,
+ onPrev,
+ onNext,
+}: {
+ readonly executionId?: string;
+ readonly query: ReturnType>;
+ readonly onClose: () => void;
+ readonly prevRowId?: string;
+ readonly nextRowId?: string;
+ readonly onPrev?: () => void;
+ readonly onNext?: () => void;
+}) {
+ const [tab, setTab] = React.useState("properties");
+ const [copied, setCopied] = React.useState(false);
+
+ const envelope = query.data;
+
+ const handleCopyJson = React.useCallback(() => {
+ if (!envelope) return;
+ const tryParse = (value: string | null): unknown => {
+ if (value === null) return null;
+ try {
+ return JSON.parse(value);
+ } catch {
+ return value;
+ }
+ };
+
+ const cleaned = {
+ ...envelope,
+ execution: {
+ ...envelope.execution,
+ resultJson: tryParse(envelope.execution.resultJson),
+ logsJson: tryParse(envelope.execution.logsJson),
+ },
+ };
+ void navigator.clipboard.writeText(JSON.stringify(cleaned, null, 2)).then(() => {
+ setCopied(true);
+ window.setTimeout(() => setCopied(false), 1500);
+ });
+ }, [envelope]);
+
+ return (
+
+
+ Execution details
+ {executionId ?? "No execution selected"}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ setTab("properties")}
+ />
+ 0
+ ? `Tool calls · ${envelope.execution.toolCallCount}`
+ : "Tool calls"
+ }
+ active={tab === "toolCalls"}
+ onClick={() => setTab("toolCalls")}
+ />
+ setTab("logs")} />
+
+
+
+ {query.isLoading ? (
+
Loading execution…
+ ) : query.isError ? (
+
Failed to load execution details.
+ ) : envelope ? (
+ tab === "properties" ? (
+
+ ) : tab === "toolCalls" ? (
+
+ ) : (
+
+ )
+ ) : (
+
Execution not found.
+ )}
+
+
+ );
+}
+
+function TabButton(props: { label: string; active: boolean; onClick: () => void }) {
+ return (
+
+ );
+}
+
+function PropertiesTab({ envelope }: { envelope: GetExecutionResponse }) {
+ const { execution, pendingInteraction } = envelope;
+ const tone = statusTone(execution.status);
+ const trigger = triggerTone(execution.triggerKind);
+ const result = unwrapJson(execution.resultJson);
+
+ return (
+
+
+
+
+
+ {STATUS_LABELS[execution.status]}
+
+
+
+ {formatDuration(execution)}
+
+
+
+
+
+ Created
+ {execution.createdAt ? (
+
+ ) : (
+ —
+ )}
+
+
+ Started
+ {execution.startedAt ? (
+
+ ) : (
+ —
+ )}
+
+
+
+
+
+
+ via {trigger.label}
+
+ •
+ tools {execution.toolCallCount}
+
+
+
+
+ {result.formatted ? (
+
+ ) : (
+
+ )}
+
+ {execution.errorText ? (
+
+
+ Error
+
+
+ {execution.errorText}
+
+
+ ) : null}
+
+ {pendingInteraction ?
: null}
+
+ );
+}
+
+function MetaCard(props: { label: string; children: React.ReactNode }) {
+ return (
+
+
+ {props.label}
+
+
{props.children}
+
+ );
+}
+
+function EmptyPanel(props: { title: string; message: string }) {
+ return (
+
+
+ {props.title}
+
+
+ {props.message}
+
+
+ );
+}
+
+function PendingInteractionBlock({ interaction }: { interaction: ExecutionInteraction }) {
+ const request = unwrapJson(interaction.payloadJson);
+ const response = unwrapJson(interaction.responseJson);
+
+ return (
+
+
+
+
Pending interaction
+
+ {interaction.kind} — {interaction.purpose}
+
+
+
+ {interaction.status}
+
+
+
+
+ {request.formatted ? (
+
+ ) : (
+
+ )}
+ {response.formatted ? (
+
+ ) : (
+
+ )}
+
+
+ );
+}
+
+function LogsTab({ logsJson }: { logsJson: string | null }) {
+ const lines = React.useMemo(() => {
+ if (logsJson === null) return null;
+ try {
+ const parsed = JSON.parse(logsJson);
+ if (Array.isArray(parsed)) return parsed.map(String);
+ } catch {
+ return null;
+ }
+ return null;
+ }, [logsJson]);
+
+ if (!lines) {
+ const fallback = unwrapJson(logsJson);
+ if (!fallback.formatted) {
+ return (
+
+ No logs recorded.
+
+ );
+ }
+ return ;
+ }
+
+ if (lines.length === 0) {
+ return (
+
+ No logs recorded.
+
+ );
+ }
+
+ return (
+
+
+ Logs
+
+
+ {lines.map((line, index) => {
+ const isError = /\[error\]/i.test(line);
+ const isWarn = /\[warn\]/i.test(line);
+ return (
+
+ {line}
+
+ );
+ })}
+
+
+ );
+}
+
+function ToolCallsTab({ execution }: { execution: Execution }) {
+ const query = useQuery({
+ queryKey: ["execution", execution.id, "tool-calls"],
+ queryFn: () => listExecutionToolCalls(execution.id),
+ staleTime: 10_000,
+ });
+
+ if (query.isLoading) {
+ return Loading tool calls…
;
+ }
+ if (query.isError) {
+ return Failed to load tool calls.
;
+ }
+
+ const calls = query.data?.toolCalls ?? [];
+ if (calls.length === 0) {
+ return (
+
+ No tool calls recorded.
+
+ );
+ }
+
+ // Derive a time scale for the flame-graph bars.
+ const windowStart = execution.startedAt ?? calls[0]!.startedAt;
+ const windowEnd =
+ execution.completedAt ??
+ Math.max(...calls.map((call) => call.completedAt ?? call.startedAt + (call.durationMs ?? 0)));
+ const windowWidth = Math.max(1, windowEnd - windowStart);
+
+ return (
+
+ {calls.map((call) => (
+
+ ))}
+
+ );
+}
+
+function ToolCallRow({
+ call,
+ windowStart,
+ windowWidth,
+}: {
+ readonly call: ExecutionToolCall;
+ readonly windowStart: number;
+ readonly windowWidth: number;
+}) {
+ const [expanded, setExpanded] = React.useState(false);
+
+ const offsetMs = Math.max(0, call.startedAt - windowStart);
+ const durationMs = call.durationMs ?? Math.max(0, Date.now() - call.startedAt);
+ const offsetPct = (offsetMs / windowWidth) * 100;
+ const widthPct = Math.max(0.75, (durationMs / windowWidth) * 100);
+
+ const args = unwrapJson(call.argsJson);
+ const result = unwrapJson(call.resultJson);
+
+ const statusColor =
+ call.status === "failed"
+ ? "bg-destructive"
+ : call.status === "running"
+ ? "bg-blue-400 animate-pulse"
+ : "bg-primary";
+
+ return (
+
+
+
+ {expanded ? (
+
+ {args.formatted ? (
+
+ ) : (
+
+ )}
+ {call.status === "failed" && call.errorText ? (
+
+
+ Error
+
+
+ {call.errorText}
+
+
+ ) : result.formatted ? (
+
+ ) : (
+
+ )}
+
+ ) : null}
+
+ );
+}
diff --git a/packages/react/src/components/runs/filter-command-parser.ts b/packages/react/src/components/runs/filter-command-parser.ts
new file mode 100644
index 000000000..0366cf9bb
--- /dev/null
+++ b/packages/react/src/components/runs/filter-command-parser.ts
@@ -0,0 +1,172 @@
+export interface RunsFilterTokens {
+ readonly status: readonly string[];
+ readonly trigger: readonly string[];
+ readonly tool: readonly string[];
+ readonly code: string | null;
+ readonly durationMsMin: number | null;
+ readonly durationMsMax: number | null;
+ readonly from: number | null;
+ readonly to: number | null;
+}
+
+export const emptyFilterTokens = (): RunsFilterTokens => ({
+ status: [],
+ trigger: [],
+ tool: [],
+ code: null,
+ durationMsMin: null,
+ durationMsMax: null,
+ from: null,
+ to: null,
+});
+
+const RELATIVE_DURATIONS: Record = {
+ m: 60 * 1000,
+ h: 60 * 60 * 1000,
+ d: 24 * 60 * 60 * 1000,
+ w: 7 * 24 * 60 * 60 * 1000,
+};
+
+const parseRelativeMs = (literal: string): number | null => {
+ const match = /^(\d+)([mhdw])$/.exec(literal);
+ if (!match) return null;
+ const [, amount, unit] = match;
+ const base = RELATIVE_DURATIONS[unit!];
+ if (!base) return null;
+ return Number(amount) * base;
+};
+
+const parseTimestamp = (value: string): number | null => {
+ const relative = parseRelativeMs(value);
+ if (relative !== null) return Date.now() - relative;
+ const parsed = Date.parse(value);
+ return Number.isNaN(parsed) ? null : parsed;
+};
+
+export const parseFilterCommand = (input: string): RunsFilterTokens => {
+ const result: {
+ -readonly [K in keyof RunsFilterTokens]: RunsFilterTokens[K] extends readonly (infer E)[]
+ ? E[]
+ : RunsFilterTokens[K];
+ } = {
+ status: [],
+ trigger: [],
+ tool: [],
+ code: null,
+ durationMsMin: null,
+ durationMsMax: null,
+ from: null,
+ to: null,
+ };
+
+ const parts = input.trim().split(/\s+/).filter(Boolean);
+
+ for (const part of parts) {
+ const colon = part.indexOf(":");
+ if (colon === -1) continue;
+ const key = part.slice(0, colon);
+ const value = part.slice(colon + 1);
+ if (value.length === 0) continue;
+
+ switch (key) {
+ case "status": {
+ result.status.push(...value.split(",").filter(Boolean));
+ break;
+ }
+ case "trigger": {
+ result.trigger.push(...value.split(",").filter(Boolean));
+ break;
+ }
+ case "tool": {
+ result.tool.push(...value.split(",").filter(Boolean));
+ break;
+ }
+ case "code": {
+ result.code = value;
+ break;
+ }
+ case "duration_ms": {
+ if (value.startsWith(">=")) {
+ result.durationMsMin = Number(value.slice(2)) || null;
+ } else if (value.startsWith("<=")) {
+ result.durationMsMax = Number(value.slice(2)) || null;
+ } else if (value.startsWith(">")) {
+ result.durationMsMin = (Number(value.slice(1)) || 0) + 1;
+ } else if (value.startsWith("<")) {
+ result.durationMsMax = (Number(value.slice(1)) || 0) - 1;
+ } else {
+ const exact = Number(value);
+ if (!Number.isNaN(exact)) {
+ result.durationMsMin = exact;
+ result.durationMsMax = exact;
+ }
+ }
+ break;
+ }
+ case "after": {
+ const ts = parseTimestamp(value);
+ if (ts !== null) result.from = ts;
+ break;
+ }
+ case "before": {
+ const ts = parseTimestamp(value);
+ if (ts !== null) result.to = ts;
+ break;
+ }
+ default:
+ // Unknown keys are silently dropped so the input stays forgiving.
+ }
+ }
+
+ return result;
+};
+
+export type FilterCommandKey = {
+ readonly key: "status" | "trigger" | "tool" | "code" | "duration_ms" | "after" | "before";
+ readonly description: string;
+ readonly example: string;
+ readonly hints?: readonly string[];
+};
+
+export const FILTER_COMMAND_KEYS: readonly FilterCommandKey[] = [
+ {
+ key: "status",
+ description: "Execution status",
+ example: "status:failed,completed",
+ hints: ["failed", "completed", "running", "waiting"],
+ },
+ {
+ key: "trigger",
+ description: "Entry point that started the run",
+ example: "trigger:mcp",
+ hints: ["mcp", "http", "cli"],
+ },
+ {
+ key: "tool",
+ description: "Tool path (supports * glob)",
+ example: "tool:github.*",
+ hints: ["namespace.*", "exact.path"],
+ },
+ {
+ key: "code",
+ description: "Substring of the run's source code",
+ example: "code:axiom",
+ },
+ {
+ key: "duration_ms",
+ description: "Duration comparator in ms",
+ example: "duration_ms:>5000",
+ hints: [">5000", "<1000"],
+ },
+ {
+ key: "after",
+ description: "Newer than a relative or absolute date",
+ example: "after:1h",
+ hints: ["15m", "1h", "24h", "7d"],
+ },
+ {
+ key: "before",
+ description: "Older than a relative or absolute date",
+ example: "before:2026-04-11",
+ },
+];
diff --git a/packages/react/src/components/runs/filter-command.tsx b/packages/react/src/components/runs/filter-command.tsx
new file mode 100644
index 000000000..d93d55e34
--- /dev/null
+++ b/packages/react/src/components/runs/filter-command.tsx
@@ -0,0 +1,259 @@
+"use client";
+
+import * as React from "react";
+import type { ExecutionListMeta } from "@executor/sdk";
+
+import { cn } from "../../lib/utils";
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+ CommandSeparator,
+ CommandShortcut,
+} from "../command";
+import {
+ FILTER_COMMAND_KEYS,
+ parseFilterCommand,
+ type RunsFilterTokens,
+} from "./filter-command-parser";
+
+export interface RunsFilterCommandProps {
+ readonly meta?: ExecutionListMeta;
+ readonly onApply: (tokens: RunsFilterTokens) => void;
+ readonly value: string;
+ readonly onValueChange: (value: string) => void;
+ /**
+ * Called whenever the dropdown's open state changes. Parent uses this
+ * to guard conflicting hotkeys (e.g. `b` → rail collapse) while the
+ * palette is active.
+ */
+ readonly onOpenChange?: (open: boolean) => void;
+}
+
+export const RunsFilterCommand = React.forwardRef(
+ function RunsFilterCommand({ meta, onApply, value, onValueChange, onOpenChange }, forwardedRef) {
+ const [open, setOpen] = React.useState(false);
+ const containerRef = React.useRef(null);
+ const inputRef = React.useRef(null);
+
+ React.useImperativeHandle(forwardedRef, () => inputRef.current as HTMLInputElement, []);
+
+ React.useEffect(() => {
+ onOpenChange?.(open);
+ }, [open, onOpenChange]);
+
+ React.useEffect(() => {
+ if (!open) return;
+ const handlePointer = (event: PointerEvent) => {
+ const target = event.target as Node | null;
+ if (!containerRef.current || !target) return;
+ if (!containerRef.current.contains(target)) {
+ setOpen(false);
+ }
+ };
+ window.addEventListener("pointerdown", handlePointer);
+ return () => window.removeEventListener("pointerdown", handlePointer);
+ }, [open]);
+
+ const currentKey = React.useMemo(() => {
+ const match = KEY_PATTERN.exec(value);
+ return match ? match[1]! : null;
+ }, [value]);
+
+ const suggestions = React.useMemo(() => {
+ if (!currentKey) return [];
+ switch (currentKey) {
+ case "status":
+ return Object.entries(meta?.statusCounts ?? {}).map(([status, count]) => ({
+ label: status,
+ hint: `${count}`,
+ }));
+ case "trigger":
+ return Object.entries(meta?.triggerCounts ?? {}).map(([trigger, count]) => ({
+ label: trigger,
+ hint: `${count}`,
+ }));
+ case "tool":
+ return (meta?.toolFacets ?? []).map((facet) => ({
+ label: facet.toolPath,
+ hint: `${facet.count}`,
+ }));
+ default:
+ return [];
+ }
+ }, [currentKey, meta]);
+
+ const handleApply = () => {
+ const tokens = parseFilterCommand(value);
+ onApply(tokens);
+ setOpen(false);
+ inputRef.current?.blur();
+ };
+
+ const handleSuggestionSelect = (suggestion: string) => {
+ const match = KEY_PATTERN.exec(value);
+ const updated = match
+ ? `${value.slice(0, match.index)}${match[1]!}:${suggestion}`
+ : value
+ ? `${value} ${suggestion}`
+ : suggestion;
+ onValueChange(updated);
+ inputRef.current?.focus();
+ };
+
+ const handleKeyInsert = (key: string) => {
+ const trimmed = value.trimEnd();
+ onValueChange(trimmed.length === 0 ? `${key}:` : `${trimmed} ${key}:`);
+ inputRef.current?.focus();
+ };
+
+ return (
+
+
{
+ if (event.key === "Enter") {
+ event.preventDefault();
+ handleApply();
+ } else if (event.key === "Escape") {
+ event.preventDefault();
+ setOpen(false);
+ inputRef.current?.blur();
+ }
+ }}
+ >
+ setOpen(true)}
+ placeholder="Filter runs — status:… trigger:… tool:… code:…"
+ className="font-mono text-xs"
+ />
+
+ {open ? (
+
+
+ {suggestions.length > 0 ? (
+
+ {suggestions.map((suggestion) => (
+ handleSuggestionSelect(suggestion.label)}
+ >
+ {suggestion.label}
+ {suggestion.hint}
+
+ ))}
+
+ ) : (
+
+
+ No live suggestions for this token.
+
+
+ )}
+
+
+
+
+ {FILTER_COMMAND_KEYS.map((entry) => (
+ handleKeyInsert(entry.key)}
+ className="group"
+ >
+ {entry.key}:
+ {entry.description}
+ {entry.hints && entry.hints.length > 0 ? (
+
+ {entry.hints.map((hint) => (
+
+ {hint}
+
+ ))}
+
+ ) : null}
+
+ ))}
+
+
+
+
+
+
+ Apply filters
+ Enter
+
+
+
+
+
+
+ Use
+
+ ↑↓
+
+ to navigate
+
+ ·
+
+
+ Enter
+
+ to query
+
+ ·
+
+
+ Esc
+
+ to close
+
+ ·
+
+ Union:{" "}
+
+ status:failed,completed
+
+
+ ·
+
+ Range:{" "}
+
+ duration_ms:>5000
+
+
+ ·
+
+ Time:{" "}
+
+ after:1h
+
+
+
+
+ ) : null}
+
+
+ );
+ },
+);
+
+const KEY_PATTERN = /(status|trigger|tool|code|duration_ms|after|before):([^\s]*)$/;
+
diff --git a/packages/react/src/components/runs/filter-rail.tsx b/packages/react/src/components/runs/filter-rail.tsx
new file mode 100644
index 000000000..5059ab71a
--- /dev/null
+++ b/packages/react/src/components/runs/filter-rail.tsx
@@ -0,0 +1,374 @@
+import * as React from "react";
+import type { ExecutionListMeta, ExecutionStatus } from "@executor/sdk";
+
+import { cn } from "../../lib/utils";
+import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "../accordion";
+import { Input } from "../input";
+import { STATUS_ORDER, STATUS_LABELS, TRIGGER_ORDER, statusTone, triggerTone } from "./status";
+
+export interface RunsFilterRailProps {
+ readonly selectedStatuses: readonly ExecutionStatus[];
+ readonly onToggleStatus: (status: ExecutionStatus) => void;
+ readonly onOnlyStatus: (status: ExecutionStatus) => void;
+ readonly selectedTriggers: readonly string[];
+ readonly onToggleTrigger: (trigger: string) => void;
+ readonly onOnlyTrigger: (trigger: string) => void;
+ /**
+ * Tri-state interactions filter: `"true"` → runs that elicited,
+ * `"false"` → runs that didn't, `null` → no filter. Only one of
+ * the two rows can be checked at a time.
+ */
+ readonly selectedElicitation: "true" | "false" | null;
+ readonly onToggleElicitation: (value: "true" | "false") => void;
+ readonly selectedTools: readonly string[];
+ readonly onToggleTool: (toolPath: string) => void;
+ readonly onOnlyTool: (toolPath: string) => void;
+ readonly range: TimeRangePreset;
+ readonly onRangeChange: (range: TimeRangePreset) => void;
+ readonly codeQuery: string;
+ readonly onCodeQueryChange: (value: string) => void;
+ readonly onReset: () => void;
+ readonly meta?: ExecutionListMeta;
+ readonly totalsLine?: string;
+}
+
+export type TimeRangePreset = "15m" | "1h" | "24h" | "7d" | "30d" | "all";
+
+export const TIME_RANGE_PRESETS: readonly {
+ readonly value: TimeRangePreset;
+ readonly label: string;
+}[] = [
+ { value: "15m", label: "Last 15m" },
+ { value: "1h", label: "Last 1h" },
+ { value: "24h", label: "Last 24h" },
+ { value: "7d", label: "Last 7d" },
+ { value: "30d", label: "Last 30d" },
+ { value: "all", label: "All time" },
+];
+
+/** Resolve a preset to an epoch-ms [from, to] pair. `to` is always "now". */
+export const resolveTimeRange = (
+ preset: TimeRangePreset,
+): { readonly from?: number; readonly to?: number } => {
+ if (preset === "all") return {};
+ const now = Date.now();
+ const deltaMs: Record, number> = {
+ "15m": 15 * 60 * 1000,
+ "1h": 60 * 60 * 1000,
+ "24h": 24 * 60 * 60 * 1000,
+ "7d": 7 * 24 * 60 * 60 * 1000,
+ "30d": 30 * 24 * 60 * 60 * 1000,
+ };
+ return { from: now - deltaMs[preset], to: now };
+};
+
+export function RunsFilterRail({
+ selectedStatuses,
+ onToggleStatus,
+ onOnlyStatus,
+ selectedTriggers,
+ onToggleTrigger,
+ onOnlyTrigger,
+ selectedElicitation,
+ onToggleElicitation,
+ selectedTools,
+ onToggleTool,
+ onOnlyTool,
+ range,
+ onRangeChange,
+ codeQuery,
+ onCodeQueryChange,
+ onReset,
+ meta,
+ totalsLine,
+}: RunsFilterRailProps) {
+ const filtersActive =
+ selectedStatuses.length > 0 ||
+ selectedTriggers.length > 0 ||
+ selectedTools.length > 0 ||
+ selectedElicitation !== null ||
+ codeQuery.trim().length > 0 ||
+ range !== "24h";
+
+ const triggerKeys = React.useMemo(() => {
+ const set = new Set(TRIGGER_ORDER);
+ if (meta?.triggerCounts) {
+ for (const key of Object.keys(meta.triggerCounts)) set.add(key);
+ }
+ return [...set].sort();
+ }, [meta?.triggerCounts]);
+
+ const toolFacets = meta?.toolFacets ?? [];
+
+ return (
+
+ {/* Title block */}
+
+
Execution history
+
+ Every execution recorded for this scope, newest first.
+
+ {totalsLine ? (
+
+ {totalsLine}
+
+ ) : null}
+
+
+ {/* Filters header + reset */}
+
+
+ Filters
+
+ {filtersActive ? (
+
+ ) : null}
+
+
+
+
+ );
+}
+
+function FacetSection({
+ value,
+ label,
+ children,
+}: {
+ readonly value: string;
+ readonly label: string;
+ readonly children: React.ReactNode;
+}) {
+ return (
+
+
+ {label}
+
+ {children}
+
+ );
+}
+
+function FacetRow({
+ checked,
+ onToggle,
+ onOnly,
+ dotClass,
+ label,
+ count,
+ monoLabel,
+}: {
+ readonly checked: boolean;
+ readonly onToggle: () => void;
+ readonly onOnly?: () => void;
+ readonly dotClass: string;
+ readonly label: string;
+ readonly count: number | undefined;
+ readonly monoLabel?: boolean;
+}) {
+ return (
+
+
+
+ {onOnly ? (
+
+ ) : null}
+
+ );
+}
diff --git a/packages/react/src/components/runs/hover-card-timestamp.tsx b/packages/react/src/components/runs/hover-card-timestamp.tsx
new file mode 100644
index 000000000..a982518b8
--- /dev/null
+++ b/packages/react/src/components/runs/hover-card-timestamp.tsx
@@ -0,0 +1,97 @@
+"use client";
+
+import * as React from "react";
+import { UTCDate } from "@date-fns/utc";
+import { format, formatDistanceToNowStrict } from "date-fns";
+import { Check, Copy } from "lucide-react";
+import type { ComponentPropsWithoutRef } from "react";
+
+import { cn } from "../../lib/utils";
+import { HoverCard, HoverCardContent, HoverCardTrigger } from "../hover-card";
+
+type HoverCardContentProps = ComponentPropsWithoutRef;
+
+export interface HoverCardTimestampProps {
+ readonly date: Date;
+ readonly side?: HoverCardContentProps["side"];
+ readonly sideOffset?: HoverCardContentProps["sideOffset"];
+ readonly align?: HoverCardContentProps["align"];
+ readonly alignOffset?: HoverCardContentProps["alignOffset"];
+ readonly className?: string;
+}
+
+export function HoverCardTimestamp({
+ date,
+ side = "right",
+ align = "start",
+ alignOffset = -4,
+ sideOffset,
+ className,
+}: HoverCardTimestampProps) {
+ const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
+
+ return (
+
+
+
+ {format(date, "LLL dd, y HH:mm:ss")}
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function CopyRow({ value, label }: { readonly value: string; readonly label: string }) {
+ const [copied, setCopied] = React.useState(false);
+
+ const handleCopy = React.useCallback(
+ (event: React.MouseEvent) => {
+ event.stopPropagation();
+ void navigator.clipboard.writeText(value).then(() => {
+ setCopied(true);
+ window.setTimeout(() => setCopied(false), 1500);
+ });
+ },
+ [value],
+ );
+
+ return (
+ {
+ if (event.key === "Enter" || event.key === " ") {
+ event.preventDefault();
+ void navigator.clipboard.writeText(value).then(() => {
+ setCopied(true);
+ window.setTimeout(() => setCopied(false), 1500);
+ });
+ }
+ }}
+ >
+
{label}
+
+
+ {copied ? : }
+
+ {value}
+
+
+ );
+}
diff --git a/packages/react/src/components/runs/keyboard-help.tsx b/packages/react/src/components/runs/keyboard-help.tsx
new file mode 100644
index 000000000..5aa62407c
--- /dev/null
+++ b/packages/react/src/components/runs/keyboard-help.tsx
@@ -0,0 +1,63 @@
+import * as React from "react";
+import { Keyboard } from "lucide-react";
+
+import { Button } from "../button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuTrigger,
+} from "../dropdown-menu";
+
+const SHORTCUTS: ReadonlyArray<{
+ readonly label: string;
+ readonly key: string;
+}> = [
+ { label: "Toggle live refresh", key: "J" },
+ { label: "Refresh data", key: "R" },
+ { label: "Open filter command", key: "/" },
+ { label: "Toggle filter rail", key: "B" },
+ { label: "Previous run in drawer", key: "↑" },
+ { label: "Next run in drawer", key: "↓" },
+ { label: "Close drawer / dialog", key: "Esc" },
+ { label: "Show this menu", key: "?" },
+];
+
+export interface KeyboardHelpButtonProps {
+ readonly open?: boolean;
+ readonly onOpenChange?: (open: boolean) => void;
+}
+
+export function KeyboardHelpButton({ open, onOpenChange }: KeyboardHelpButtonProps) {
+ return (
+
+
+
+
+
+
+ Keyboard shortcuts
+
+
+ {SHORTCUTS.map((shortcut) => (
+
+ {shortcut.label}
+ {shortcut.key}
+
+ ))}
+
+
+ );
+}
diff --git a/packages/react/src/components/runs/live-button.tsx b/packages/react/src/components/runs/live-button.tsx
new file mode 100644
index 000000000..6cf53154b
--- /dev/null
+++ b/packages/react/src/components/runs/live-button.tsx
@@ -0,0 +1,28 @@
+import { CirclePause, CirclePlay } from "lucide-react";
+
+import { cn } from "../../lib/utils";
+import { Button } from "../button";
+
+export interface LiveButtonProps {
+ readonly active: boolean;
+ readonly onClick: () => void;
+}
+
+export function LiveButton({ active, onClick }: LiveButtonProps) {
+ return (
+
+ );
+}
diff --git a/packages/react/src/components/runs/live-row.tsx b/packages/react/src/components/runs/live-row.tsx
new file mode 100644
index 000000000..c5b536a77
--- /dev/null
+++ b/packages/react/src/components/runs/live-row.tsx
@@ -0,0 +1,14 @@
+import * as React from "react";
+
+export function LiveRow() {
+ return (
+
+
+ ● Live · now
+
+
+ );
+}
diff --git a/packages/react/src/components/runs/refresh-button.tsx b/packages/react/src/components/runs/refresh-button.tsx
new file mode 100644
index 000000000..591a96f60
--- /dev/null
+++ b/packages/react/src/components/runs/refresh-button.tsx
@@ -0,0 +1,29 @@
+import { LoaderCircle, RefreshCcw } from "lucide-react";
+
+import { Button } from "../button";
+
+export interface RefreshButtonProps {
+ readonly onClick: () => void;
+ readonly isLoading?: boolean;
+}
+
+export function RefreshButton({ onClick, isLoading }: RefreshButtonProps) {
+ return (
+
+ );
+}
diff --git a/packages/react/src/components/runs/row.tsx b/packages/react/src/components/runs/row.tsx
new file mode 100644
index 000000000..4a194bb12
--- /dev/null
+++ b/packages/react/src/components/runs/row.tsx
@@ -0,0 +1,171 @@
+import * as React from "react";
+import type { Execution } from "@executor/sdk";
+
+import { cn } from "../../lib/utils";
+import { HoverCardTimestamp } from "./hover-card-timestamp";
+import { statusTone, triggerTone } from "./status";
+
+const formatDurationMs = (execution: Execution): string | null => {
+ if (execution.startedAt === null || execution.completedAt === null) return null;
+ const ms = Math.max(0, execution.completedAt - execution.startedAt);
+ return ms.toLocaleString();
+};
+
+
+/** Count `[error]` and `[warn]` lines in the serialized logsJson array. */
+const parseLogCounts = (logsJson: string | null): { errors: number; warns: number } => {
+ if (!logsJson) return { errors: 0, warns: 0 };
+ try {
+ const parsed = JSON.parse(logsJson);
+ if (!Array.isArray(parsed)) return { errors: 0, warns: 0 };
+ let errors = 0;
+ let warns = 0;
+ for (const line of parsed) {
+ if (typeof line !== "string") continue;
+ if (line.startsWith("[error]")) errors += 1;
+ else if (line.startsWith("[warn]")) warns += 1;
+ }
+ return { errors, warns };
+ } catch {
+ return { errors: 0, warns: 0 };
+ }
+};
+
+export interface RunRowProps {
+ readonly execution: Execution;
+ readonly isSelected?: boolean;
+ /**
+ * True when the row is "past" the live cutoff — i.e., it already
+ * existed at the moment live mode was turned on. Rendered at half
+ * opacity so new arrivals stand out.
+ */
+ readonly isPast?: boolean;
+ /**
+ * Per-field visibility from the ViewOptionsButton. Missing keys
+ * default to visible. `status` and `code` are always shown.
+ */
+ readonly visibleFields?: {
+ readonly via?: boolean;
+ readonly tools?: boolean;
+ readonly log?: boolean;
+ readonly duration_ms?: boolean;
+ };
+ readonly onSelect?: () => void;
+}
+
+export function RunRow({ execution, isSelected, isPast, visibleFields, onSelect }: RunRowProps) {
+ const showVia = visibleFields?.via !== false;
+ const showTools = visibleFields?.tools !== false;
+ const showLog = visibleFields?.log !== false;
+ const showDuration = visibleFields?.duration_ms !== false;
+
+ const durationMs = formatDurationMs(execution);
+ const durationNumeric = durationMs ? Number(durationMs.replace(/,/g, "")) : null;
+ const isSlow = durationNumeric !== null && durationNumeric > 5_000;
+ const tone = statusTone(execution.status);
+ const trigger = triggerTone(execution.triggerKind);
+ const logs = React.useMemo(() => parseLogCounts(execution.logsJson), [execution.logsJson]);
+
+ return (
+
+ );
+}
diff --git a/packages/react/src/components/runs/shell.tsx b/packages/react/src/components/runs/shell.tsx
new file mode 100644
index 000000000..5c3ddb81c
--- /dev/null
+++ b/packages/react/src/components/runs/shell.tsx
@@ -0,0 +1,162 @@
+import * as React from "react";
+
+import { cn } from "../../lib/utils";
+import { LiveRow } from "./live-row";
+
+export interface RunsShellProps {
+ readonly filterRail: React.ReactNode;
+ readonly topBar?: React.ReactNode;
+ readonly chartSlot?: React.ReactNode;
+ readonly columnHeader?: React.ReactNode;
+ readonly emptyState?: React.ReactNode;
+ readonly rows: readonly T[];
+ readonly getRowId: (row: T) => string;
+ readonly renderRow: (row: T) => React.ReactNode;
+ /** Row id before which to render a `` divider (live mode cutoff). */
+ readonly liveMarkerBeforeRowId?: string;
+ readonly isLoading?: boolean;
+ readonly isFetchingNextPage?: boolean;
+ readonly hasNextPage?: boolean;
+ readonly fetchNextPage?: () => void;
+ readonly totalRowsFetched?: number;
+ readonly filterRowCount?: number;
+ /** When true, hide the filter rail and let the main pane fill the width. */
+ readonly collapseRail?: boolean;
+ readonly className?: string;
+}
+
+export function RunsShell({
+ filterRail,
+ topBar,
+ chartSlot,
+ columnHeader,
+ emptyState,
+ rows,
+ getRowId,
+ renderRow,
+ liveMarkerBeforeRowId,
+ isLoading,
+ isFetchingNextPage,
+ hasNextPage,
+ fetchNextPage,
+ totalRowsFetched = 0,
+ filterRowCount,
+ collapseRail,
+ className,
+}: RunsShellProps) {
+ const topBarRef = React.useRef(null);
+ const bodyRef = React.useRef(null);
+ const [topBarHeight, setTopBarHeight] = React.useState(0);
+
+ React.useEffect(() => {
+ const topBar = topBarRef.current;
+ if (!topBar) return;
+
+ const observer = new ResizeObserver(() => {
+ const rect = topBar.getBoundingClientRect();
+ setTopBarHeight(rect.height);
+ });
+
+ observer.observe(topBar);
+ return () => observer.disconnect();
+ }, []);
+
+ const onScroll = React.useCallback(
+ (event: React.UIEvent) => {
+ if (!fetchNextPage || !hasNextPage || isFetchingNextPage) return;
+
+ const target = event.currentTarget;
+ const onPageBottom =
+ Math.ceil(target.scrollTop + target.clientHeight) >= target.scrollHeight - 64;
+
+ if (onPageBottom) {
+ const hitFilterCeiling =
+ typeof filterRowCount === "number" && totalRowsFetched >= filterRowCount;
+ if (!hitFilterCeiling) {
+ fetchNextPage();
+ }
+ }
+ },
+ [fetchNextPage, hasNextPage, isFetchingNextPage, totalRowsFetched, filterRowCount],
+ );
+
+ return (
+
+
+
+
+
+ {topBar}
+ {chartSlot}
+
+
+ {columnHeader ? (
+
+ {columnHeader}
+
+ ) : null}
+
+
+ {isLoading ? (
+
+ Loading runs…
+
+ ) : rows.length === 0 ? (
+
+ {emptyState ??
No runs.
}
+
+ ) : (
+ <>
+ {rows.map((row) => {
+ const id = getRowId(row);
+ return (
+
+ {id === liveMarkerBeforeRowId ? : null}
+ {renderRow(row)}
+
+ );
+ })}
+ {isFetchingNextPage ? (
+
+ Loading more…
+
+ ) : null}
+ {!hasNextPage && totalRowsFetched > 0 ? (
+
+ End of history
+
+ ) : null}
+ >
+ )}
+
+
+
+ );
+}
diff --git a/packages/react/src/components/runs/status.ts b/packages/react/src/components/runs/status.ts
new file mode 100644
index 000000000..b9951ff5e
--- /dev/null
+++ b/packages/react/src/components/runs/status.ts
@@ -0,0 +1,108 @@
+import type { ExecutionStatus } from "@executor/sdk";
+
+export const STATUS_ORDER = [
+ "running",
+ "waiting_for_interaction",
+ "completed",
+ "failed",
+ "cancelled",
+ "pending",
+] as const satisfies readonly ExecutionStatus[];
+
+export const STATUS_LABELS: Record = {
+ pending: "Pending",
+ running: "Running",
+ waiting_for_interaction: "Waiting",
+ completed: "Completed",
+ failed: "Failed",
+ cancelled: "Cancelled",
+};
+
+export type StatusTone = {
+ /** Tailwind bg-* class for the solid dot. */
+ readonly dot: string;
+ /** Tailwind text-* class for the inline status label. */
+ readonly text: string;
+ /** CSS value suitable for recharts bar `fill`. */
+ readonly chartFill: string;
+ /** Whether to apply `animate-pulse` to the dot. */
+ readonly pulse: boolean;
+};
+
+export const STATUS_TONES: Record = {
+ completed: {
+ dot: "bg-primary",
+ text: "text-primary",
+ chartFill: "var(--primary)",
+ pulse: false,
+ },
+ failed: {
+ dot: "bg-destructive",
+ text: "text-destructive",
+ chartFill: "var(--destructive)",
+ pulse: false,
+ },
+ running: {
+ dot: "bg-blue-400",
+ text: "text-blue-400",
+ chartFill: "#60a5fa",
+ pulse: true,
+ },
+ waiting_for_interaction: {
+ dot: "bg-amber-400",
+ text: "text-amber-400",
+ chartFill: "#fbbf24",
+ pulse: true,
+ },
+ cancelled: {
+ dot: "bg-muted-foreground/60",
+ text: "text-muted-foreground",
+ chartFill: "var(--muted-foreground)",
+ pulse: false,
+ },
+ pending: {
+ dot: "bg-muted-foreground/40",
+ text: "text-muted-foreground",
+ chartFill: "color-mix(in srgb, var(--muted-foreground) 50%, transparent)",
+ pulse: false,
+ },
+};
+
+export const statusTone = (status: ExecutionStatus): StatusTone => STATUS_TONES[status];
+
+export type TriggerTone = {
+ readonly dot: string;
+ readonly text: string;
+ readonly label: string;
+};
+
+const UNKNOWN_TRIGGER_TONE: TriggerTone = {
+ dot: "bg-muted-foreground/40",
+ text: "text-muted-foreground",
+ label: "unknown",
+};
+
+export const TRIGGER_TONES: Record = {
+ mcp: {
+ dot: "bg-[color:var(--color-info)]",
+ text: "text-[color:var(--color-info)]",
+ label: "mcp",
+ },
+ http: {
+ dot: "bg-[color:var(--color-success)]",
+ text: "text-[color:var(--color-success)]",
+ label: "http",
+ },
+ cli: {
+ dot: "bg-foreground/70",
+ text: "text-foreground",
+ label: "cli",
+ },
+};
+
+export const triggerTone = (kind: string | null | undefined): TriggerTone => {
+ if (!kind) return UNKNOWN_TRIGGER_TONE;
+ return TRIGGER_TONES[kind] ?? { ...UNKNOWN_TRIGGER_TONE, label: kind };
+};
+
+export const TRIGGER_ORDER = ["mcp", "http", "cli"] as const;
diff --git a/packages/react/src/components/runs/timeline-chart.tsx b/packages/react/src/components/runs/timeline-chart.tsx
new file mode 100644
index 000000000..ac908c95a
--- /dev/null
+++ b/packages/react/src/components/runs/timeline-chart.tsx
@@ -0,0 +1,200 @@
+"use client";
+
+import * as React from "react";
+import { format } from "date-fns";
+import { Bar, BarChart, CartesianGrid, ReferenceArea, XAxis } from "recharts";
+
+type RechartsMouseEvent = { readonly activeLabel?: string | number };
+import type { ExecutionChartBucket } from "@executor/sdk";
+
+import { cn } from "../../lib/utils";
+import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from "../chart";
+import { STATUS_LABELS, STATUS_TONES } from "./status";
+
+const TIMELINE_CONFIG: ChartConfig = {
+ completed: { label: STATUS_LABELS.completed, color: STATUS_TONES.completed.chartFill },
+ failed: { label: STATUS_LABELS.failed, color: STATUS_TONES.failed.chartFill },
+ running: { label: STATUS_LABELS.running, color: STATUS_TONES.running.chartFill },
+ waiting_for_interaction: {
+ label: STATUS_LABELS.waiting_for_interaction,
+ color: STATUS_TONES.waiting_for_interaction.chartFill,
+ },
+ cancelled: { label: STATUS_LABELS.cancelled, color: STATUS_TONES.cancelled.chartFill },
+ pending: { label: STATUS_LABELS.pending, color: STATUS_TONES.pending.chartFill },
+};
+
+const BAR_STACK_ORDER = [
+ "failed",
+ "cancelled",
+ "waiting_for_interaction",
+ "running",
+ "pending",
+ "completed",
+] as const;
+
+const pickAxisLabelFormatter = (bucketMs: number) => {
+ const MIN = 60_000;
+ const HOUR = 60 * MIN;
+ const DAY = 24 * HOUR;
+
+ if (bucketMs <= MIN) {
+ return (value: string) => {
+ const date = new Date(value);
+ return Number.isNaN(date.getTime()) ? "—" : format(date, "HH:mm:ss");
+ };
+ }
+ if (bucketMs < HOUR) {
+ return (value: string) => {
+ const date = new Date(value);
+ return Number.isNaN(date.getTime()) ? "—" : format(date, "HH:mm");
+ };
+ }
+ if (bucketMs < DAY) {
+ return (value: string) => {
+ const date = new Date(value);
+ return Number.isNaN(date.getTime()) ? "—" : format(date, "HH:mm");
+ };
+ }
+ return (value: string) => {
+ const date = new Date(value);
+ return Number.isNaN(date.getTime()) ? "—" : format(date, "LLL dd");
+ };
+};
+
+const pickTooltipLabelFormatter = (bucketMs: number) => {
+ const MIN = 60_000;
+ const HOUR = 60 * MIN;
+ return (value: unknown) => {
+ const date = new Date(value as string);
+ if (Number.isNaN(date.getTime())) return "—";
+ if (bucketMs <= MIN) return format(date, "LLL dd, HH:mm:ss");
+ if (bucketMs < HOUR) return format(date, "LLL dd, HH:mm");
+ return format(date, "LLL dd, y HH:mm");
+ };
+};
+
+export interface TimelineChartProps {
+ readonly data: readonly ExecutionChartBucket[];
+ readonly bucketMs: number;
+ readonly className?: string;
+ readonly onRangeSelect?: (range: { readonly from: number; readonly to: number }) => void;
+}
+
+export function TimelineChart({ data, bucketMs, className, onRangeSelect }: TimelineChartProps) {
+ const [refAreaLeft, setRefAreaLeft] = React.useState(null);
+ const [refAreaRight, setRefAreaRight] = React.useState(null);
+ const isSelectingRef = React.useRef(false);
+
+ const chartRows = React.useMemo(
+ () =>
+ data.map((bucket) => ({
+ ...bucket,
+ date: new Date(bucket.timestamp).toISOString(),
+ })),
+ [data],
+ );
+
+ const axisLabelFormatter = React.useMemo(() => pickAxisLabelFormatter(bucketMs), [bucketMs]);
+ const tooltipLabelFormatter = React.useMemo(
+ () => pickTooltipLabelFormatter(bucketMs),
+ [bucketMs],
+ );
+
+ const handleMouseDown = (event: RechartsMouseEvent | null | undefined) => {
+ if (event?.activeLabel != null) {
+ setRefAreaLeft(String(event.activeLabel));
+ isSelectingRef.current = true;
+ }
+ };
+
+ const handleMouseMove = (event: RechartsMouseEvent | null | undefined) => {
+ if (isSelectingRef.current && event?.activeLabel != null) {
+ setRefAreaRight(String(event.activeLabel));
+ }
+ };
+
+ const handleMouseUp = () => {
+ if (refAreaLeft && refAreaRight && onRangeSelect) {
+ const [lStr, rStr] = [refAreaLeft, refAreaRight].sort(
+ (a, b) => new Date(a).getTime() - new Date(b).getTime(),
+ );
+ const from = new Date(lStr).getTime();
+ const to = new Date(rStr).getTime() + bucketMs; // include the bucket
+ if (from < to) {
+ onRangeSelect({ from, to });
+ }
+ }
+ setRefAreaLeft(null);
+ setRefAreaRight(null);
+ isSelectingRef.current = false;
+ };
+
+ if (chartRows.length === 0) {
+ return (
+
+ No activity in range
+
+ );
+ }
+
+ return (
+
+
+
+
+ }
+ />
+ {BAR_STACK_ORDER.map((status) => (
+
+ ))}
+ {refAreaLeft && refAreaRight ? (
+
+ ) : null}
+
+
+ );
+}
diff --git a/packages/react/src/components/runs/view-options-button.tsx b/packages/react/src/components/runs/view-options-button.tsx
new file mode 100644
index 000000000..e62b6257e
--- /dev/null
+++ b/packages/react/src/components/runs/view-options-button.tsx
@@ -0,0 +1,95 @@
+import * as React from "react";
+import { Settings2 } from "lucide-react";
+
+import { cn } from "../../lib/utils";
+import { Button } from "../button";
+import { Popover, PopoverContent, PopoverTrigger } from "../popover";
+
+// ---------------------------------------------------------------------------
+// ViewOptionsButton — row field visibility toggle
+// ---------------------------------------------------------------------------
+//
+// Adapted from openstatus `data-table-view-options.tsx` (which toggles column
+// visibility). We're flat-row so there are no columns — instead, this toggles
+// which inline key-value fields the row renders. Persisted to localStorage via
+// the `useLocalStorage` hook so the user's preferences survive reloads.
+
+export type RunFieldKey = "via" | "tools" | "log" | "duration_ms";
+
+export const RUN_FIELD_LABELS: Record = {
+ via: "Trigger (via:)",
+ tools: "Tool calls (tools:)",
+ log: "Log levels (log:)",
+ duration_ms: "Duration (duration_ms:)",
+};
+
+export const DEFAULT_FIELD_VISIBILITY: Record = {
+ via: true,
+ tools: true,
+ log: true,
+ duration_ms: true,
+};
+
+const FIELD_ORDER: readonly RunFieldKey[] = ["via", "tools", "log", "duration_ms"];
+
+export interface ViewOptionsButtonProps {
+ readonly visible: Record;
+ readonly onToggle: (key: RunFieldKey) => void;
+}
+
+export function ViewOptionsButton({ visible, onToggle }: ViewOptionsButtonProps) {
+ return (
+
+
+
+
+
+
+ Row fields
+
+ {FIELD_ORDER.map((field) => {
+ const checked = visible[field] !== false;
+ return (
+
+ );
+ })}
+
+
+ );
+}
diff --git a/packages/react/src/hooks/use-live-mode.ts b/packages/react/src/hooks/use-live-mode.ts
new file mode 100644
index 000000000..7362b2ce8
--- /dev/null
+++ b/packages/react/src/hooks/use-live-mode.ts
@@ -0,0 +1,42 @@
+import * as React from "react";
+
+export interface LiveModeState {
+ readonly cutoffTimestamp: number | null;
+ readonly cutoffRow: T | null;
+ readonly isPast: (createdAt: number) => boolean;
+}
+
+export function useLiveMode(
+ rows: readonly T[],
+ live: boolean,
+): LiveModeState {
+ const [cutoffTimestamp, setCutoffTimestamp] = React.useState(null);
+
+ const onLiveChange = React.useEffectEvent((isLive: boolean) => {
+ if (!isLive) {
+ setCutoffTimestamp(null);
+ return;
+ }
+ const newest = rows[0];
+ setCutoffTimestamp(newest ? newest.createdAt : Date.now());
+ });
+
+ React.useEffect(() => {
+ onLiveChange(live);
+ }, [live]);
+
+ const cutoffRow = React.useMemo(() => {
+ if (cutoffTimestamp === null) return null;
+ return rows.find((row) => row.createdAt <= cutoffTimestamp) ?? null;
+ }, [rows, cutoffTimestamp]);
+
+ const isPast = React.useCallback(
+ (createdAt: number): boolean => {
+ if (cutoffTimestamp === null) return false;
+ return createdAt <= cutoffTimestamp;
+ },
+ [cutoffTimestamp],
+ );
+
+ return { cutoffTimestamp, cutoffRow, isPast };
+}
diff --git a/packages/react/src/hooks/use-local-storage.ts b/packages/react/src/hooks/use-local-storage.ts
new file mode 100644
index 000000000..3a8fd35d6
--- /dev/null
+++ b/packages/react/src/hooks/use-local-storage.ts
@@ -0,0 +1,53 @@
+import * as React from "react";
+
+// ---------------------------------------------------------------------------
+// useLocalStorage — minimal JSON-backed persistent state hook
+// ---------------------------------------------------------------------------
+//
+// SSR-safe (reads lazily from `window.localStorage` inside useEffect so it
+// works with TanStack Start's hydration). Writes on every state change.
+// Falls back to the initial value when parsing fails or localStorage is
+// unavailable (private browsing, quota exceeded, etc.).
+
+export function useLocalStorage(
+ key: string,
+ initialValue: T,
+): readonly [T, (value: T | ((prev: T) => T)) => void] {
+ const [value, setValue] = React.useState(initialValue);
+ const [isHydrated, setIsHydrated] = React.useState(false);
+
+ // Hydrate from localStorage on mount. Done in a one-shot effect so the
+ // initial SSR render matches the client default, then swaps in the
+ // stored value on the first client tick.
+ React.useEffect(() => {
+ try {
+ const stored = window.localStorage.getItem(key);
+ if (stored !== null) {
+ setValue(JSON.parse(stored) as T);
+ }
+ } catch {
+ // Parse error or localStorage unavailable — keep initial value.
+ }
+ setIsHydrated(true);
+ }, [key]);
+
+ const setPersistedValue = React.useCallback(
+ (next: T | ((prev: T) => T)) => {
+ setValue((prev) => {
+ const resolved = typeof next === "function" ? (next as (p: T) => T)(prev) : next;
+ if (isHydrated) {
+ try {
+ window.localStorage.setItem(key, JSON.stringify(resolved));
+ } catch {
+ // Quota exceeded or localStorage unavailable — state still
+ // updates in memory.
+ }
+ }
+ return resolved;
+ });
+ },
+ [key, isHydrated],
+ );
+
+ return [value, setPersistedValue] as const;
+}
diff --git a/packages/react/src/lib/shiki.ts b/packages/react/src/lib/shiki.ts
index 021673207..b8184044f 100644
--- a/packages/react/src/lib/shiki.ts
+++ b/packages/react/src/lib/shiki.ts
@@ -12,8 +12,8 @@ import langTsx from "@shikijs/langs/tsx";
import langJsx from "@shikijs/langs/jsx";
import langJson from "@shikijs/langs/json";
import langShellscript from "@shikijs/langs/shellscript";
-import githubDark from "@shikijs/themes/github-dark";
-import githubLight from "@shikijs/themes/github-light";
+import vitesseDark from "@shikijs/themes/vitesse-dark";
+import vitesseLight from "@shikijs/themes/vitesse-light";
// ---------------------------------------------------------------------------
// Lazily loaded languages — imported on first use
@@ -104,11 +104,11 @@ const LAZY_LANG_LOADERS: Partial Promise>>
const supportedSet = new Set([...SUPPORTED_LANGS, ...Object.keys(LANG_ALIASES)]);
-export const SUPPORTED_THEMES = ["github-dark", "github-light"] as const;
+export const SUPPORTED_THEMES = ["vitesse-dark", "vitesse-light"] as const;
export type SupportedTheme = (typeof SUPPORTED_THEMES)[number];
-export const DEFAULT_LIGHT_THEME: SupportedTheme = "github-light";
-export const DEFAULT_DARK_THEME: SupportedTheme = "github-dark";
+export const DEFAULT_LIGHT_THEME: SupportedTheme = "vitesse-light";
+export const DEFAULT_DARK_THEME: SupportedTheme = "vitesse-dark";
export type ShikiThemeProp = SupportedTheme | { light: SupportedTheme; dark: SupportedTheme };
@@ -144,7 +144,7 @@ export function isSupportedLang(lang: string): boolean {
// ---------------------------------------------------------------------------
const highlighter: HighlighterCore = createHighlighterCoreSync({
- themes: [githubDark, githubLight],
+ themes: [vitesseDark, vitesseLight],
langs: [langTypescript, langJavascript, langTsx, langJsx, langJson, langShellscript],
engine: createJavaScriptRegexEngine({ forgiving: true }),
});
diff --git a/packages/react/src/pages/runs.tsx b/packages/react/src/pages/runs.tsx
new file mode 100644
index 000000000..6d56f0383
--- /dev/null
+++ b/packages/react/src/pages/runs.tsx
@@ -0,0 +1,497 @@
+import * as React from "react";
+import { useInfiniteQuery } from "@tanstack/react-query";
+import { useNavigate } from "@tanstack/react-router";
+import type { ExecutionStatus } from "@executor/sdk";
+
+import { listExecutions, type ExecutionListItem } from "../api/executions";
+import { useHotkeys } from "react-hotkeys-hook";
+import { useLiveMode } from "../hooks/use-live-mode";
+import { useLocalStorage } from "../hooks/use-local-storage";
+import { RunsShell } from "../components/runs/shell";
+import { RunRow } from "../components/runs/row";
+import { RunsColumnHeader, type SortField, type SortState } from "../components/runs/column-header";
+import {
+ RunsFilterRail,
+ resolveTimeRange,
+ type TimeRangePreset,
+} from "../components/runs/filter-rail";
+import { TimelineChart } from "../components/runs/timeline-chart";
+import { RunsDetailDrawer } from "../components/runs/detail-drawer";
+import { LiveButton } from "../components/runs/live-button";
+import { RefreshButton } from "../components/runs/refresh-button";
+import { KeyboardHelpButton } from "../components/runs/keyboard-help";
+import {
+ ViewOptionsButton,
+ DEFAULT_FIELD_VISIBILITY,
+ type RunFieldKey,
+} from "../components/runs/view-options-button";
+import { RunsFilterCommand } from "../components/runs/filter-command";
+import type { RunsFilterTokens } from "../components/runs/filter-command-parser";
+import { STATUS_ORDER } from "../components/runs/status";
+
+export type RunsSearch = {
+ readonly executionId?: string;
+ readonly status?: string;
+ readonly trigger?: string;
+ readonly tool?: string;
+ readonly range?: string;
+ readonly from?: string;
+ readonly to?: string;
+ readonly code?: string;
+ readonly live?: string;
+ readonly sort?: string;
+ readonly elicitation?: string;
+};
+
+const DEFAULT_RANGE: TimeRangePreset = "24h";
+const VALID_RANGES: readonly TimeRangePreset[] = ["15m", "1h", "24h", "7d", "30d", "all"];
+const PAGE_SIZE = 50;
+const LIVE_REFRESH_INTERVAL_MS = 5_000;
+
+const splitCsv = (value: string | undefined): string[] =>
+ value ? value.split(",").map((s) => s.trim()).filter((s) => s.length > 0) : [];
+
+const parseStatuses = (value: string | undefined): ExecutionStatus[] =>
+ splitCsv(value).filter((s): s is ExecutionStatus => STATUS_ORDER.includes(s as ExecutionStatus));
+
+const toggleCsv = (values: readonly string[], value: string): string[] =>
+ values.includes(value) ? values.filter((entry) => entry !== value) : [...values, value].sort();
+
+const VALID_SORT_FIELDS: readonly SortField[] = ["createdAt", "durationMs"];
+
+const parseSortSearch = (value: string | undefined): SortState => {
+ if (!value) return null;
+ const [field, direction] = value.split(",");
+ if (!field || !direction) return null;
+ if (!VALID_SORT_FIELDS.includes(field as SortField)) return null;
+ if (direction !== "asc" && direction !== "desc") return null;
+ return { field: field as SortField, direction };
+};
+
+
+export function RunsPage({ search }: { search: RunsSearch }) {
+ const navigate = useNavigate();
+
+ const selectedStatuses = React.useMemo(() => parseStatuses(search.status), [search.status]);
+ const selectedTriggers = React.useMemo(() => splitCsv(search.trigger), [search.trigger]);
+ const selectedTools = React.useMemo(() => splitCsv(search.tool), [search.tool]);
+ const range = React.useMemo(
+ (): TimeRangePreset =>
+ search.range && VALID_RANGES.includes(search.range as TimeRangePreset)
+ ? (search.range as TimeRangePreset)
+ : DEFAULT_RANGE,
+ [search.range],
+ );
+ const sort = React.useMemo(() => parseSortSearch(search.sort), [search.sort]);
+ const selectedElicitation: "true" | "false" | null =
+ search.elicitation === "true" || search.elicitation === "false" ? search.elicitation : null;
+ const live = search.live === "1";
+
+ const [codeInput, setCodeInput] = React.useState(search.code ?? "");
+
+ React.useEffect(() => {
+ setCodeInput(search.code ?? "");
+ }, [search.code]);
+
+ const updateSearch = React.useCallback(
+ (patch: Partial) => {
+ void navigate({
+ to: "/runs",
+ replace: true,
+ search: (current: RunsSearch) => {
+ const next = { ...current, ...patch };
+ const cleaned: Record = {};
+ for (const [key, value] of Object.entries(next)) {
+ if (value && String(value).length > 0) {
+ cleaned[key] = String(value);
+ }
+ }
+ return cleaned as RunsSearch;
+ },
+ });
+ },
+ [navigate],
+ );
+
+ React.useEffect(() => {
+ const trimmed = codeInput.trim();
+ const current = search.code ?? "";
+ if (trimmed === current) return;
+
+ const timeout = window.setTimeout(() => {
+ updateSearch({ code: trimmed || undefined, executionId: undefined });
+ }, 250);
+ return () => window.clearTimeout(timeout);
+ }, [codeInput, search.code, updateSearch]);
+
+ const resolvedTimeRange = React.useMemo(() => {
+ if (search.from || search.to) {
+ return {
+ from: search.from ? Number(search.from) : undefined,
+ to: search.to ? Number(search.to) : undefined,
+ };
+ }
+ return resolveTimeRange(range);
+ }, [range, search.from, search.to]);
+
+ const listQuery = useInfiniteQuery({
+ queryKey: [
+ "executions",
+ selectedStatuses.join(","),
+ selectedTriggers.join(","),
+ selectedTools.join(","),
+ resolvedTimeRange.from ?? "",
+ resolvedTimeRange.to ?? "",
+ search.code ?? "",
+ search.sort ?? "",
+ search.elicitation ?? "",
+ ],
+ initialPageParam: undefined as string | undefined,
+ queryFn: ({ pageParam }) =>
+ listExecutions({
+ limit: PAGE_SIZE,
+ cursor: pageParam,
+ status: selectedStatuses.length > 0 ? selectedStatuses.join(",") : undefined,
+ trigger: selectedTriggers.length > 0 ? selectedTriggers.join(",") : undefined,
+ tool: selectedTools.length > 0 ? selectedTools.join(",") : undefined,
+ from: resolvedTimeRange.from ? String(resolvedTimeRange.from) : undefined,
+ to: resolvedTimeRange.to ? String(resolvedTimeRange.to) : undefined,
+ code: search.code,
+ sort: search.sort,
+ elicitation: search.elicitation,
+ }),
+ getNextPageParam: (page) => page.nextCursor,
+ staleTime: 10_000,
+ refetchInterval: live ? LIVE_REFRESH_INTERVAL_MS : false,
+ refetchIntervalInBackground: false,
+ });
+
+ const rows = React.useMemo(
+ () => listQuery.data?.pages.flatMap((page) => page.executions) ?? [],
+ [listQuery.data],
+ );
+
+ const liveMode = useLiveMode(rows, live);
+
+ const selectedIndex = React.useMemo(
+ () => (search.executionId ? rows.findIndex((r) => r.id === search.executionId) : -1),
+ [rows, search.executionId],
+ );
+ const prevRowId = selectedIndex > 0 ? rows[selectedIndex - 1]?.id : undefined;
+ const nextRowId =
+ selectedIndex >= 0 && selectedIndex < rows.length - 1 ? rows[selectedIndex + 1]?.id : undefined;
+
+ const meta = listQuery.data?.pages[0]?.meta;
+
+ const totalsLine = meta
+ ? `${meta.filterRowCount.toLocaleString()} of ${meta.totalRowCount.toLocaleString()} runs`
+ : undefined;
+
+ const handleToggleStatus = React.useCallback(
+ (status: ExecutionStatus) => {
+ const next = toggleCsv(selectedStatuses, status) as ExecutionStatus[];
+ updateSearch({
+ status: next.length > 0 ? next.join(",") : undefined,
+ executionId: undefined,
+ });
+ },
+ [selectedStatuses, updateSearch],
+ );
+
+ const handleToggleTrigger = React.useCallback(
+ (trigger: string) => {
+ const next = toggleCsv(selectedTriggers, trigger);
+ updateSearch({
+ trigger: next.length > 0 ? next.join(",") : undefined,
+ executionId: undefined,
+ });
+ },
+ [selectedTriggers, updateSearch],
+ );
+
+ const handleToggleTool = React.useCallback(
+ (toolPath: string) => {
+ const next = toggleCsv(selectedTools, toolPath);
+ updateSearch({
+ tool: next.length > 0 ? next.join(",") : undefined,
+ executionId: undefined,
+ });
+ },
+ [selectedTools, updateSearch],
+ );
+
+ const handleToggleElicitation = React.useCallback(
+ (value: "true" | "false") => {
+ updateSearch({
+ elicitation: selectedElicitation === value ? undefined : value,
+ executionId: undefined,
+ });
+ },
+ [selectedElicitation, updateSearch],
+ );
+
+ const handleSort = React.useCallback(
+ (field: SortField) => {
+ const next: SortState =
+ sort?.field !== field
+ ? { field, direction: "desc" }
+ : sort.direction === "desc"
+ ? { field, direction: "asc" }
+ : null;
+ updateSearch({
+ sort: next ? `${next.field},${next.direction}` : undefined,
+ executionId: undefined,
+ });
+ },
+ [sort, updateSearch],
+ );
+
+ const handleOnlyStatus = React.useCallback(
+ (status: ExecutionStatus) => updateSearch({ status, executionId: undefined }),
+ [updateSearch],
+ );
+ const handleOnlyTrigger = React.useCallback(
+ (trigger: string) => updateSearch({ trigger, executionId: undefined }),
+ [updateSearch],
+ );
+ const handleOnlyTool = React.useCallback(
+ (tool: string) => updateSearch({ tool, executionId: undefined }),
+ [updateSearch],
+ );
+
+ const handleRangeChange = React.useCallback(
+ (nextRange: TimeRangePreset) => {
+ updateSearch({
+ range: nextRange,
+ from: undefined,
+ to: undefined,
+ executionId: undefined,
+ });
+ },
+ [updateSearch],
+ );
+
+ const handleCodeQueryChange = React.useCallback((value: string) => {
+ setCodeInput(value);
+ }, []);
+
+ const handleReset = React.useCallback(() => {
+ setCodeInput("");
+ updateSearch({
+ status: undefined,
+ trigger: undefined,
+ tool: undefined,
+ range: DEFAULT_RANGE,
+ from: undefined,
+ to: undefined,
+ code: undefined,
+ elicitation: undefined,
+ executionId: undefined,
+ });
+ }, [updateSearch]);
+
+ const handleChartRangeSelect = React.useCallback(
+ ({ from, to }: { from: number; to: number }) => {
+ updateSearch({
+ range: undefined,
+ from: String(from),
+ to: String(to),
+ executionId: undefined,
+ });
+ },
+ [updateSearch],
+ );
+
+ const handleRowSelect = React.useCallback(
+ (execution: ExecutionListItem) => {
+ updateSearch({
+ executionId: search.executionId === execution.id ? undefined : execution.id,
+ });
+ },
+ [search.executionId, updateSearch],
+ );
+
+ const handleDrawerOpenChange = React.useCallback(
+ (open: boolean) => {
+ if (!open) {
+ updateSearch({ executionId: undefined });
+ }
+ },
+ [updateSearch],
+ );
+
+ const toggleLive = React.useCallback(() => {
+ updateSearch({ live: live ? undefined : "1" });
+ }, [live, updateSearch]);
+
+ const filterCommandInputRef = React.useRef(null);
+ const [filterCommandValue, setFilterCommandValue] = React.useState("");
+ const [filterCommandOpen, setFilterCommandOpen] = React.useState(false);
+ const [keyboardHelpOpen, setKeyboardHelpOpen] = React.useState(false);
+ const [railCollapsed, setRailCollapsed] = React.useState(false);
+
+ const [fieldVisibility, setFieldVisibility] = useLocalStorage>(
+ "runs.fieldVisibility",
+ DEFAULT_FIELD_VISIBILITY,
+ );
+
+ const toggleFieldVisibility = React.useCallback(
+ (key: RunFieldKey) => {
+ setFieldVisibility((prev) => ({ ...prev, [key]: !prev[key] }));
+ },
+ [setFieldVisibility],
+ );
+
+ const currentFilterExpression = React.useMemo(() => {
+ const parts: string[] = [];
+ if (selectedStatuses.length > 0) parts.push(`status:${selectedStatuses.join(",")}`);
+ if (selectedTriggers.length > 0) parts.push(`trigger:${selectedTriggers.join(",")}`);
+ if (selectedTools.length > 0) parts.push(`tool:${selectedTools.join(",")}`);
+ if (search.code) parts.push(`code:${search.code}`);
+ return parts.join(" ");
+ }, [selectedStatuses, selectedTriggers, selectedTools, search.code]);
+
+ React.useEffect(() => {
+ setFilterCommandValue(currentFilterExpression);
+ }, [currentFilterExpression]);
+
+ const handleApplyFilterCommand = React.useCallback(
+ (tokens: RunsFilterTokens) => {
+ const statusValue = (tokens.status as ExecutionStatus[]).filter((s) =>
+ STATUS_ORDER.includes(s),
+ );
+
+ updateSearch({
+ status: statusValue.length > 0 ? statusValue.join(",") : undefined,
+ trigger: tokens.trigger.length > 0 ? [...tokens.trigger].join(",") : undefined,
+ tool: tokens.tool.length > 0 ? [...tokens.tool].join(",") : undefined,
+ code: tokens.code ?? undefined,
+ from: tokens.from ? String(tokens.from) : undefined,
+ to: tokens.to ? String(tokens.to) : undefined,
+ range: tokens.from || tokens.to ? undefined : undefined,
+ executionId: undefined,
+ });
+ },
+ [updateSearch],
+ );
+
+ useHotkeys("j", toggleLive, { enabled: !filterCommandOpen });
+ useHotkeys("r", () => void listQuery.refetch(), { enabled: !filterCommandOpen });
+ useHotkeys("/", () => filterCommandInputRef.current?.focus(), { preventDefault: true });
+ useHotkeys("shift+/", () => setKeyboardHelpOpen(true), { preventDefault: true });
+ useHotkeys("b", () => setRailCollapsed((prev) => !prev), {
+ enabled: !filterCommandOpen,
+ });
+
+ return (
+ <>
+
+ }
+ topBar={
+
+
+
+
+ {rows.length.toLocaleString()} loaded
+
+ {meta ? (
+
+ · {meta.filterRowCount.toLocaleString()} total
+
+ ) : null}
+
+
+ void listQuery.refetch()}
+ isLoading={listQuery.isRefetching}
+ />
+
+
+
+
+
+
+
+ }
+ chartSlot={
+ meta ? (
+
+ ) : null
+ }
+ columnHeader={
+
+ }
+ isLoading={listQuery.isLoading}
+ isFetchingNextPage={listQuery.isFetchingNextPage}
+ hasNextPage={listQuery.hasNextPage}
+ fetchNextPage={() => void listQuery.fetchNextPage()}
+ totalRowsFetched={rows.length}
+ filterRowCount={meta?.filterRowCount}
+ rows={rows}
+ getRowId={(row) => row.id}
+ collapseRail={railCollapsed}
+ renderRow={(row) => (
+ handleRowSelect(row)}
+ />
+ )}
+ liveMarkerBeforeRowId={liveMode.cutoffRow?.id}
+ emptyState={
+
+
+ No runs match the current filters.
+
+
+ Try widening the time range or removing the status filter.
+
+
+ }
+ />
+
+ prevRowId && updateSearch({ executionId: prevRowId })}
+ onNext={() => nextRowId && updateSearch({ executionId: nextRowId })}
+ />
+ >
+ );
+}
diff --git a/packages/react/src/styles/globals.css b/packages/react/src/styles/globals.css
index bddf02aae..5ed11e358 100644
--- a/packages/react/src/styles/globals.css
+++ b/packages/react/src/styles/globals.css
@@ -30,6 +30,10 @@
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
+ --color-success: var(--success);
+ --color-warning: var(--warning);
+ --color-error: var(--error);
+ --color-info: var(--info);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
@@ -67,6 +71,10 @@
--accent: oklch(0.94 0.006 260);
--accent-foreground: oklch(0.145 0.005 260);
--destructive: oklch(0.55 0.22 25);
+ --success: oklch(0.62 0.15 150);
+ --warning: oklch(0.72 0.16 85);
+ --error: oklch(0.58 0.22 25);
+ --info: oklch(0.62 0.12 245);
--border: oklch(0.88 0.006 260);
--input: oklch(0.88 0.006 260);
--ring: oklch(0.55 0.17 175);
@@ -103,6 +111,10 @@
--accent: oklch(0.195 0.008 260);
--accent-foreground: oklch(0.9 0.008 250);
--destructive: oklch(0.62 0.22 25);
+ --success: oklch(0.78 0.16 150);
+ --warning: oklch(0.8 0.14 85);
+ --error: oklch(0.72 0.2 25);
+ --info: oklch(0.76 0.11 245);
--border: oklch(0.28 0.007 260);
--input: oklch(0.195 0.007 260);
--ring: oklch(0.72 0.15 175);