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/routeTree.gen.ts b/apps/local/src/routeTree.gen.ts
index b068e7fb3..e98f21d74 100644
--- a/apps/local/src/routeTree.gen.ts
+++ b/apps/local/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 ConnectionsRouteImport } from './routes/connections'
import { Route as IndexRouteImport } from './routes/index'
import { Route as SourcesNamespaceRouteImport } from './routes/sources.$namespace'
@@ -21,6 +22,11 @@ const ToolsRoute = ToolsRouteImport.update({
path: '/tools',
getParentRoute: () => rootRouteImport,
} as any)
+const RunsRoute = RunsRouteImport.update({
+ id: '/runs',
+ path: '/runs',
+ getParentRoute: () => rootRouteImport,
+} as any)
const SecretsRoute = SecretsRouteImport.update({
id: '/secrets',
path: '/secrets',
@@ -50,6 +56,7 @@ const SourcesAddPluginKeyRoute = SourcesAddPluginKeyRouteImport.update({
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/connections': typeof ConnectionsRoute
+ '/runs': typeof RunsRoute
'/secrets': typeof SecretsRoute
'/tools': typeof ToolsRoute
'/sources/$namespace': typeof SourcesNamespaceRoute
@@ -58,6 +65,7 @@ export interface FileRoutesByFullPath {
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/connections': typeof ConnectionsRoute
+ '/runs': typeof RunsRoute
'/secrets': typeof SecretsRoute
'/tools': typeof ToolsRoute
'/sources/$namespace': typeof SourcesNamespaceRoute
@@ -67,6 +75,7 @@ export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/connections': typeof ConnectionsRoute
+ '/runs': typeof RunsRoute
'/secrets': typeof SecretsRoute
'/tools': typeof ToolsRoute
'/sources/$namespace': typeof SourcesNamespaceRoute
@@ -77,6 +86,7 @@ export interface FileRouteTypes {
fullPaths:
| '/'
| '/connections'
+ | '/runs'
| '/secrets'
| '/tools'
| '/sources/$namespace'
@@ -85,6 +95,7 @@ export interface FileRouteTypes {
to:
| '/'
| '/connections'
+ | '/runs'
| '/secrets'
| '/tools'
| '/sources/$namespace'
@@ -93,6 +104,7 @@ export interface FileRouteTypes {
| '__root__'
| '/'
| '/connections'
+ | '/runs'
| '/secrets'
| '/tools'
| '/sources/$namespace'
@@ -102,6 +114,7 @@ export interface FileRouteTypes {
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
ConnectionsRoute: typeof ConnectionsRoute
+ RunsRoute: typeof RunsRoute
SecretsRoute: typeof SecretsRoute
ToolsRoute: typeof ToolsRoute
SourcesNamespaceRoute: typeof SourcesNamespaceRoute
@@ -124,6 +137,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
+ }
'/connections': {
id: '/connections'
path: '/connections'
@@ -158,6 +178,7 @@ declare module '@tanstack/react-router' {
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
ConnectionsRoute: ConnectionsRoute,
+ RunsRoute: RunsRoute,
SecretsRoute: SecretsRoute,
ToolsRoute: ToolsRoute,
SourcesNamespaceRoute: SourcesNamespaceRoute,
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/bun.lock b/bun.lock
index 89db25bd4..35237c272 100644
--- a/bun.lock
+++ b/bun.lock
@@ -766,6 +766,7 @@
"version": "1.4.3",
"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:",
@@ -775,10 +776,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",
@@ -787,6 +790,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",
@@ -1061,6 +1065,8 @@
"@date-fns/tz": ["@date-fns/tz@1.4.1", "", {}, "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA=="],
+ "@date-fns/utc": ["@date-fns/utc@2.1.1", "", {}, "sha512-SlJDfG6RPeEX8wEVv6ZB3kak4MmbtyiI2qX/5zuKdordbrhB/iaJ58GVMZgJ6P1sJaM1gMgENFYYeg1JWrCFrA=="],
+
"@develar/schema-utils": ["@develar/schema-utils@2.6.5", "", { "dependencies": { "ajv": "^6.12.0", "ajv-keywords": "^3.4.1" } }, "sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig=="],
"@dnd-kit/accessibility": ["@dnd-kit/accessibility@3.1.1", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw=="],
@@ -2819,7 +2825,7 @@
"dagre-d3-es": ["dagre-d3-es@7.0.14", "", { "dependencies": { "d3": "^7.9.0", "lodash-es": "^4.17.21" } }, "sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg=="],
- "date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="],
+ "date-fns": ["date-fns@3.6.0", "", {}, "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww=="],
"date-fns-jalali": ["date-fns-jalali@4.1.0-0", "", {}, "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg=="],
@@ -4753,6 +4759,8 @@
"@babel/traverse/@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
+ "@base-ui/react/date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="],
+
"@changesets/apply-release-plan/prettier": ["prettier@2.8.8", "", { "bin": { "prettier": "bin-prettier.js" } }, "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q=="],
"@changesets/write/prettier": ["prettier@2.8.8", "", { "bin": { "prettier": "bin-prettier.js" } }, "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q=="],
@@ -5245,6 +5253,8 @@
"rc-menu/@rc-component/trigger": ["@rc-component/trigger@2.3.1", "", { "dependencies": { "@babel/runtime": "^7.23.2", "@rc-component/portal": "^1.1.0", "classnames": "^2.3.2", "rc-motion": "^2.0.0", "rc-resize-observer": "^1.3.1", "rc-util": "^5.44.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-ORENF39PeXTzM+gQEshuk460Z8N4+6DkjpxlpE7Q3gYy1iBpLrx0FOJz3h62ryrJZ/3zCAUIkT1Pb/8hHWpb3A=="],
+ "react-day-picker/date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="],
+
"react-rnd/tslib": ["tslib@2.6.2", "", {}, "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q=="],
"read-yaml-file/js-yaml": ["js-yaml@3.14.2", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg=="],
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..008dc0910
--- /dev/null
+++ b/packages/react/src/api/executions.tsx
@@ -0,0 +1,220 @@
+import { endOfDay, parseISO, startOfDay } from "date-fns";
+
+import { getBaseUrl } from "./base-url";
+
+// ---------------------------------------------------------------------------
+// Wire-format row projections. The server returns epoch-ms numbers for
+// every timestamp (handlers stringify/unwrap Effect Schema `Date`s at
+// the edge), so the UI works with plain numbers throughout instead of
+// reusing the SDK's Schema classes that decode to `Date`.
+// ---------------------------------------------------------------------------
+
+export type ExecutionStatus =
+ | "pending"
+ | "running"
+ | "waiting_for_interaction"
+ | "completed"
+ | "failed"
+ | "cancelled";
+
+export type Execution = {
+ readonly id: string;
+ readonly scopeId: string;
+ readonly status: ExecutionStatus;
+ readonly code: string;
+ readonly resultJson: string | null;
+ readonly errorText: string | null;
+ readonly logsJson: string | null;
+ readonly startedAt: number | null;
+ readonly completedAt: number | null;
+ readonly triggerKind: string | null;
+ readonly triggerMetaJson: string | null;
+ readonly toolCallCount: number;
+ readonly createdAt: number;
+ readonly updatedAt: number;
+};
+
+export type ExecutionInteraction = {
+ readonly id: string;
+ readonly executionId: string;
+ readonly status: "pending" | "resolved" | "cancelled";
+ readonly kind: string;
+ readonly purpose: string | null;
+ readonly payloadJson: string | null;
+ readonly responseJson: string | null;
+ readonly responsePrivateJson: string | null;
+ readonly createdAt: number;
+ readonly updatedAt: number;
+};
+
+export type ExecutionToolCall = {
+ readonly id: string;
+ readonly executionId: string;
+ readonly status: "running" | "completed" | "failed";
+ readonly toolPath: string;
+ readonly namespace: string | null;
+ readonly argsJson: string | null;
+ readonly resultJson: string | null;
+ readonly errorText: string | null;
+ readonly startedAt: number;
+ readonly completedAt: number | null;
+ readonly durationMs: number | null;
+};
+
+export type ExecutionChartBucket = {
+ readonly bucketStart: number;
+ readonly counts: Readonly
>;
+};
+
+export type ExecutionListMeta = {
+ readonly totalRowCount: number;
+ readonly filterRowCount: number;
+ readonly statusCounts: ReadonlyArray<{
+ readonly status: ExecutionStatus;
+ readonly count: number;
+ }>;
+ readonly triggerCounts: ReadonlyArray<{
+ readonly triggerKind: string | null;
+ readonly count: number;
+ }>;
+ readonly toolFacets: ReadonlyArray<{
+ readonly toolPath: string;
+ readonly count: number;
+ }>;
+ readonly interactionCounts: {
+ readonly withElicitation: number;
+ readonly withoutElicitation: number;
+ };
+ readonly chartBucketMs: number;
+ readonly chartData: ReadonlyArray;
+};
+
+/**
+ * Flat list item shape consumed by the runs UI. The server returns
+ * `{ execution, pendingInteraction }` nested; we flatten here so every
+ * component can read `row.id` / `row.createdAt` / `row.pendingInteraction`
+ * without going through `.execution`.
+ */
+export type ExecutionListItem = Execution & {
+ readonly pendingInteraction: ExecutionInteraction | null;
+};
+
+export type ListExecutionsResponse = {
+ readonly executions: readonly ExecutionListItem[];
+ readonly nextCursor?: string;
+ readonly meta?: ExecutionListMeta;
+};
+
+export type GetExecutionResponse = {
+ readonly execution: Execution;
+ readonly pendingInteraction: ExecutionInteraction | null;
+};
+
+export type ListToolCallsResponse = {
+ readonly toolCalls: readonly ExecutionToolCall[];
+};
+
+type ServerListItem = {
+ readonly execution: Execution;
+ readonly pendingInteraction: ExecutionInteraction | null;
+};
+
+type ServerListResponse = {
+ readonly executions: readonly ServerListItem[];
+ readonly nextCursor?: string;
+ readonly meta?: ExecutionListMeta;
+};
+
+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",
+ });
+
+ const payload = await readJson(response);
+ return {
+ executions: payload.executions.map(
+ (item): ExecutionListItem => ({
+ ...item.execution,
+ pendingInteraction: item.pendingInteraction,
+ }),
+ ),
+ ...(payload.nextCursor ? { nextCursor: payload.nextCursor } : {}),
+ ...(payload.meta ? { meta: payload.meta } : {}),
+ };
+};
+
+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/runs/column-header.tsx b/packages/react/src/components/runs/column-header.tsx
new file mode 100644
index 000000000..4168d727b
--- /dev/null
+++ b/packages/react/src/components/runs/column-header.tsx
@@ -0,0 +1,100 @@
+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 (
+ // oxlint-disable-next-line react/forbid-elements -- column headers are dense table-level affordances;