Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 19 additions & 43 deletions apps/web/src/command-palette-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
type PaletteSource,
type RecentEntry,
} from "@/command-palette";
import { useQueryClient } from "@tanstack/react-query";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";

import { listAgentDefinitions } from "./agents-api";
Expand Down Expand Up @@ -183,49 +183,25 @@ export function CommandPaletteProvider({
// fetches for (by design — the unscoped default view should not dump
// every entity on open). The mock shows every item in an active scope for
// this input, so fetch that scope's raw list directly instead.
const [bareWorkbenches, setBareWorkbenches] = useState<readonly PaletteResultItem[]>([]);
const [bareAgents, setBareAgents] = useState<readonly PaletteResultItem[]>([]);

useEffect(() => {
if (bareScopeKind !== "workbenches" || !open) {
setBareWorkbenches([]);
return;
}
let cancelled = false;
void listWorkbenchesForSearch().then((rows) => {
if (cancelled) return;
setBareWorkbenches(
rows.map((row) => ({
id: `entity:workbenches:${row.id}`,
title: row.name,
})),
);
});
return () => {
cancelled = true;
};
}, [bareScopeKind, open, listWorkbenchesForSearch]);
const bareWorkbenchesQuery = useQuery({
queryKey: [...tenantKeys.workbenches(selectedTenantId ?? "", "workbench"), "bare-scope"],
enabled: bareScopeKind === "workbenches" && open && selectedTenantId !== null,
queryFn: listWorkbenchesForSearch,
});
const bareWorkbenches: readonly PaletteResultItem[] = (bareWorkbenchesQuery.data ?? []).map(
(row) => ({ id: `entity:workbenches:${row.id}`, title: row.name }),
);

useEffect(() => {
if (bareScopeKind !== "people" || !open) {
setBareAgents([]);
return;
}
let cancelled = false;
void listAgentsForSearch().then((rows) => {
if (cancelled) return;
setBareAgents(
rows.map((row) => ({
id: `entity:agents:${row.id}`,
title: row.name,
subtitle: "Agent",
})),
);
});
return () => {
cancelled = true;
};
}, [bareScopeKind, open, listAgentsForSearch]);
const bareAgentsQuery = useQuery({
queryKey: ["tenant", selectedTenantId ?? "", "agents", "bare-scope"],
enabled: bareScopeKind === "people" && open && selectedTenantId !== null,
queryFn: listAgentsForSearch,
});
const bareAgents: readonly PaletteResultItem[] = (bareAgentsQuery.data ?? []).map((row) => ({
id: `entity:agents:${row.id}`,
title: row.name,
subtitle: "Agent",
}));

const routinesQuery = useTenantQuery(
tenantKeys.routines(selectedTenantId ?? ""),
Expand Down
30 changes: 13 additions & 17 deletions apps/web/src/pages/mission-control-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,7 @@ import {
} from "@corbits/react-ui";
import type { BadgeTone } from "@corbits/react-ui";
import { ChatCircleDots, Plus, Robot } from "@/lib/icons";
import { useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { useMutation, useQueryClient } from "@tanstack/react-query";

import { CHAT_STRINGS, type Workbench } from "@/chat";
import { runOutcomeStatus, runStatusLabel, withListingAbandoned } from "@corbits/workflows/client";
Expand Down Expand Up @@ -153,26 +152,23 @@ function ApprovalRow({
readonly tenantId: string;
}) {
const queryClient = useQueryClient();
const [pending, setPending] = useState<"approve" | "deny" | null>(null);

async function resolve(action: "approve" | "deny") {
setPending(action);
try {
if (action === "approve") await approveApproval(tenantId, item.id);
else await rejectApproval(tenantId, item.id);
await queryClient.invalidateQueries({
const resolveMutation = useMutation({
mutationFn: (action: "approve" | "deny") =>
action === "approve" ? approveApproval(tenantId, item.id) : rejectApproval(tenantId, item.id),
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: tenantKeys.pendingApprovals(tenantId),
});
} catch (cause) {
},
onError: (cause, action) => {
toast(
cause instanceof Error
? cause.message
: `Couldn't ${action === "approve" ? "approve" : "deny"} that request.`,
);
} finally {
setPending(null);
}
}
},
});
const pending = resolveMutation.isPending ? resolveMutation.variables : null;

return (
<TableRow>
Expand All @@ -187,15 +183,15 @@ function ApprovalRow({
variant="ghost"
size="sm"
disabled={pending !== null}
onClick={() => void resolve("deny")}
onClick={() => resolveMutation.mutate("deny")}
>
{pending === "deny" ? "Denying…" : "Deny"}
</Button>
<Button
variant="primary"
size="sm"
disabled={pending !== null}
onClick={() => void resolve("approve")}
onClick={() => resolveMutation.mutate("approve")}
>
{pending === "approve" ? "Approving…" : "Approve"}
</Button>
Expand Down
169 changes: 163 additions & 6 deletions apps/web/src/pages/workflow-detail-page.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,28 @@
// `/workflows/<definitionId>` — a workflow definition's own page. Stock's
// `GET /workflows/definitions` (`workflow-detail-api.ts`) exposes only
// name, description, status (`deployed` | `stopped`), current version,
// and timestamps, so this renders exactly that.
import { Badge, EmptyState, PageShell } from "@corbits/react-ui";
// and timestamps; runs and their event logs come from the stock
// `GET /workflows/runs?definitionId=` listing and `GET
// /workflows/runs/:runId/events` (`vendor/intx/hub-api/src/routes/runs.ts`).
import {
Badge,
EmptyState,
PageShell,
RichEmptyState,
Skeleton,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@corbits/react-ui";
import type { BadgeTone } from "@corbits/react-ui";
import { WorkflowRunResponse, paginatedSchema } from "@intx/types";
import { type } from "arktype";
import { useState } from "react";
import { Clock, FlowArrow } from "@/lib/icons";
import { useAPIQuery } from "../api";

import { useBench } from "../bench-context";
import { WORKFLOWS_PATH_PREFIX, workflowDefinitionAssetIdFromPath } from "../path-ids";
Expand All @@ -17,6 +35,137 @@ import {
type WorkflowDefinitionDetailT,
} from "../workflow-detail-api";

const RunsPageSchema = paginatedSchema(WorkflowRunResponse);
type RunRow = typeof WorkflowRunResponse.infer;

const RunEventSchema = type({ seq: "number", type: "string", body: "Record<string, unknown>" });
const RunEventsSchema = type({ runId: "string", events: RunEventSchema.array() });
type RunEvent = typeof RunEventSchema.infer;

function runsPath(tenantId: string, definitionId: string): string {
return `/api/tenants/${tenantId}/workflows/runs?definitionId=${encodeURIComponent(definitionId)}&limit=100`;
}

function runEventsPath(tenantId: string, runId: string): string {
return `/api/tenants/${tenantId}/workflows/runs/${encodeURIComponent(runId)}/events`;
}

const RUN_STATUS_TONE_BY_STATUS: Readonly<Record<RunRow["status"], BadgeTone>> = {
deployed: "success",
running: "info",
updating: "info",
error: "danger",
stopped: "neutral",
};

/** A run's event log, fetched on selection. Rendered inline under the runs
* table rather than a separate route — a definition rarely has enough runs
* to need one. */
function RunEventsPanel({
tenantId,
runId,
}: {
readonly tenantId: string;
readonly runId: string;
}) {
const eventsQuery = useAPIQuery(runEventsPath(tenantId, runId), RunEventsSchema);
if (eventsQuery.kind === "loading") return <Skeleton className="h-24 w-full" />;
if (eventsQuery.kind === "error") {
return <RichEmptyState title="Couldn't load events" description={eventsQuery.message} />;
}
if (eventsQuery.kind === "unauthenticated") return null;
const events: readonly RunEvent[] = eventsQuery.data.events;
if (events.length === 0) {
return (
<RichEmptyState
title="No events yet"
description="This run hasn't committed any events to its log."
/>
);
}
return (
<Table aria-label={`Events for run ${runId}`}>
<TableHeader>
<TableRow>
<TableHead>Seq</TableHead>
<TableHead>Type</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{events.map((event) => (
<TableRow key={event.seq}>
<TableCell>{event.seq}</TableCell>
<TableCell>{event.type}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
}

/** Runs for this definition, newest first (the listing's own order), with a
* click-to-expand event log per run. */
function WorkflowRunsSection({
tenantId,
definitionId,
}: {
readonly tenantId: string;
readonly definitionId: string;
}) {
const [selectedRunId, setSelectedRunId] = useState<string | null>(null);
const runsQuery = useAPIQuery(runsPath(tenantId, definitionId), RunsPageSchema);

return (
<section className="flex flex-col gap-3">
<h2 className="m-0 text-sm font-semibold">Runs</h2>
{runsQuery.kind === "loading" ? <Skeleton className="h-24 w-full" /> : null}
{runsQuery.kind === "error" ? (
<RichEmptyState title="Couldn't load runs" description={runsQuery.message} />
) : null}
{runsQuery.kind === "ready" && runsQuery.data.data.length === 0 ? (
<RichEmptyState title="No runs yet" description="This workflow hasn't run yet." />
) : null}
{runsQuery.kind === "ready" && runsQuery.data.data.length > 0 ? (
<Table aria-label="Runs">
<TableHeader>
<TableRow>
<TableHead>Status</TableHead>
<TableHead>Started</TableHead>
<TableHead>Ended</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{runsQuery.data.data.map((run) => (
<>
<TableRow
key={run.id}
role="button"
tabIndex={0}
className="cursor-pointer"
onClick={() => setSelectedRunId(selectedRunId === run.id ? null : run.id)}
>
<TableCell>
<Badge tone={RUN_STATUS_TONE_BY_STATUS[run.status]}>{run.status}</Badge>
</TableCell>
<TableCell>{run.createdAt}</TableCell>
<TableCell>{run.endedAt ?? "—"}</TableCell>
</TableRow>
{selectedRunId === run.id ? (
<TableRow key={`${run.id}-events`}>
<TableCell colSpan={3}>
<RunEventsPanel tenantId={tenantId} runId={run.id} />
</TableCell>
</TableRow>
) : null}
</>
))}
</TableBody>
</Table>
) : null}
</section>
);
}

const STATUS_LABEL: Readonly<Record<WorkflowDefinitionDetailT["status"], string>> = {
deployed: "Deployed",
stopped: "Stopped",
Expand Down Expand Up @@ -60,9 +209,16 @@ export function NotLaunchableStrip({
);
}

/** The whole page body, given a resolved detail — pure, so the layout is
* testable without a fetch or a router. */
export function WorkflowDetailPage({ detail }: { readonly detail: WorkflowDefinitionDetailT }) {
/** The whole page body, given a resolved detail and the tenant it lives on.
* The header/status strip render straight off `detail`; runs and their
* event logs are their own live reads (`WorkflowRunsSection`). */
export function WorkflowDetailPage({
detail,
tenantId,
}: {
readonly detail: WorkflowDefinitionDetailT;
readonly tenantId: string;
}) {
return (
<div className="flex h-full min-h-0 flex-col">
<StageTopBar
Expand All @@ -72,6 +228,7 @@ export function WorkflowDetailPage({ detail }: { readonly detail: WorkflowDefini
<div className="flex flex-col gap-6">
<WorkflowDetailHeader detail={detail} />
<NotLaunchableStrip status={detail.status} />
<WorkflowRunsSection tenantId={tenantId} definitionId={detail.definitionId} />
</div>
</PageShell>
</div>
Expand Down Expand Up @@ -132,5 +289,5 @@ export function WorkflowDetailRoute({ path }: { readonly path: string }) {
return <WorkflowNotice title="Workflow" description={detailQuery.message} />;
}

return <WorkflowDetailPage detail={detailQuery.data} />;
return <WorkflowDetailPage detail={detailQuery.data} tenantId={tenantId} />;
}
Loading
Loading