Files
kandev/apps/web/components/runs/runs-page-client.tsx
T
2026-09-21 18:22:29 +01:00

202 lines
7.3 KiB
TypeScript

"use client";
import { useTranslation } from "react-i18next";
import { useMemo, useState } from "react";
import { useAppStore } from "@/components/state-provider";
import { IconHistory, IconRefresh } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import Link from "@/components/routing/app-link";
import { PageTopbar } from "@/components/page-topbar";
import { useRouter } from "@/lib/routing/client-router";
import { linkToTask } from "@/lib/links";
import type { WorkspaceAutomationRun } from "@/lib/types/automation";
import { RunFeedItem } from "./run-feed-item";
import { ANY_AUTOMATION, RunFilters, isDefaultFilters } from "./run-filters";
import { ALL_STATUSES, type RunStatusFilter } from "./run-status";
import { AUTOMATIONS_HREF } from "./runs-view";
import { useWorkspaceRuns } from "./use-workspace-runs";
const NEW_AUTOMATION_HREF = "/settings/automations";
type RunsPageClientProps = {
workspaceId?: string;
};
/** Distinct automations present in the feed, in first-seen (newest) order. */
function automationOptions(runs: WorkspaceAutomationRun[]) {
const seen = new Map<string, string>();
for (const run of runs) {
if (!seen.has(run.automation_id)) seen.set(run.automation_id, run.automation_name);
}
return Array.from(seen, ([id, name]) => ({ id, name }));
}
function matchesFilters(
run: WorkspaceAutomationRun,
status: RunStatusFilter,
automationId: string,
): boolean {
if (status !== ALL_STATUSES && run.status !== status) return false;
return automationId === ANY_AUTOMATION || run.automation_id === automationId;
}
function NoRunsYet() {
const { t } = useTranslation();
return (
<div
className="flex flex-col items-center gap-3 py-16 text-center"
data-testid="runs-empty-state"
>
<IconHistory className="h-8 w-8 text-muted-foreground/40" />
<p className="text-sm font-medium">{t("automations:emptyRunsTitle")}</p>
<p className="max-w-md text-sm text-muted-foreground">{t("automations:emptyListBody")}</p>
<Button asChild size="sm" className="cursor-pointer">
<Link href={NEW_AUTOMATION_HREF}>{t("automations:createAnAutomation")}</Link>
</Button>
</div>
);
}
function NoFilterMatches({ onClear }: { onClear: () => void }) {
const { t } = useTranslation();
return (
<div
className="flex flex-col items-center gap-3 py-16 text-center"
data-testid="runs-filtered-empty-state"
>
<p className="text-sm text-muted-foreground">{t("automations:noRunsMatchFilters")}</p>
<Button variant="outline" size="sm" className="cursor-pointer" onClick={onClear}>
{t("automations:clearFilters")}
</Button>
</div>
);
}
type RunFeedProps = {
runs: WorkspaceAutomationRun[];
allRuns: WorkspaceAutomationRun[];
loading: boolean;
error: string | null;
onClearFilters: () => void;
onOpen: (taskId: string) => void;
};
function RunFeed({ runs, allRuns, loading, error, onClearFilters, onOpen }: RunFeedProps) {
const { t } = useTranslation();
if (error) {
return (
<p className="py-16 text-center text-sm text-destructive" data-testid="runs-error">
{error}
</p>
);
}
if (loading && allRuns.length === 0) {
return (
<p className="py-16 text-center text-sm text-muted-foreground">
{t("automations:loadingRuns")}
</p>
);
}
if (allRuns.length === 0) return <NoRunsYet />;
if (runs.length === 0) return <NoFilterMatches onClear={onClearFilters} />;
return (
<div className="flex flex-col divide-y divide-border/50" data-testid="runs-feed">
{runs.map((run) => (
<RunFeedItem key={run.id} run={run} automationName={run.automation_name} onOpen={onOpen} />
))}
</div>
);
}
export function RunsPageClient({ workspaceId }: RunsPageClientProps) {
const { t } = useTranslation();
const router = useRouter();
// The route hands down the boot payload's workspace, which is captured once
// and never changes for the life of the SPA session. /runs follows the
// ACTIVE workspace, so prefer the live store value — otherwise landing here
// after a workspace switch keeps querying whichever workspace was active at
// boot.
const activeWorkspaceId = useAppStore((state) => state.workspaces.activeId);
const effectiveWorkspaceId = activeWorkspaceId ?? workspaceId;
const { runs, loading, error, refresh } = useWorkspaceRuns(effectiveWorkspaceId);
const [status, setStatus] = useState<RunStatusFilter>(ALL_STATUSES);
const [automationId, setAutomationId] = useState<string>(ANY_AUTOMATION);
const automations = useMemo(() => automationOptions(runs), [runs]);
// An automation id only means something inside the workspace that owns it.
// After a workspace switch — or when an automation drops out of the feed —
// the chip falls back to naming "Any automation" while the filter still
// excludes everything, so the user gets an empty feed with no visible cause.
// Degrading a selection nothing can name back to "any" keeps what is shown
// and what is filtered in agreement.
const effectiveAutomationId = useMemo(
() =>
automationId === ANY_AUTOMATION || automations.some((a) => a.id === automationId)
? automationId
: ANY_AUTOMATION,
[automationId, automations],
);
const visibleRuns = useMemo(
() => runs.filter((run) => matchesFilters(run, status, effectiveAutomationId)),
[runs, status, effectiveAutomationId],
);
const clearFilters = () => {
setStatus(ALL_STATUSES);
setAutomationId(ANY_AUTOMATION);
};
return (
<div className="flex h-full min-h-0 w-full flex-col bg-background">
<PageTopbar
title={t("automations:runsTitle")}
icon={<IconHistory className="h-4 w-4" />}
backHref={AUTOMATIONS_HREF}
// Names the destination, not this page: back goes to the automations
// list, and a crumb reading "Runs" pointed at a page titled
// "Automations" left the reader guessing which one they were on.
backLabel={t("automations:automations")}
actions={
<Button
variant="ghost"
size="icon-sm"
className="cursor-pointer"
onClick={refresh}
disabled={loading}
title={t("automations:refresh")}
data-testid="runs-refresh"
>
<IconRefresh className={loading ? "h-4 w-4 animate-spin" : "h-4 w-4"} />
</Button>
}
/>
<div className="min-h-0 flex-1 overflow-y-auto">
<div className="mx-auto w-full max-w-3xl px-4 py-4">
<div className="mb-2 flex items-center justify-between gap-2">
<RunFilters
status={status}
onStatusChange={setStatus}
automationId={effectiveAutomationId}
onAutomationChange={setAutomationId}
automations={automations}
/>
{!isDefaultFilters(status, effectiveAutomationId) && (
<span className="text-xs text-muted-foreground">
{visibleRuns.length} of {runs.length}
</span>
)}
</div>
<RunFeed
runs={visibleRuns}
allRuns={runs}
loading={loading}
error={error}
onClearFilters={clearFilters}
onOpen={(taskId) => router.push(linkToTask(taskId))}
/>
</div>
</div>
</div>
);
}