Files
ai-workflow/dashboard/src/components/RoleStatus.tsx
T

229 lines
8.1 KiB
TypeScript

import { useCallback, useMemo, useState } from "react";
import { usePolling } from "../hooks/usePolling";
import { fetchRoles } from "../api/client";
import type { RoleInfo } from "../types";
import AsyncPageState from "./ui/AsyncPageState";
import Button from "./ui/Button";
import Chip from "./ui/Chip";
import RoleBadge from "./RoleBadge";
import PageHero from "./ui/PageHero";
import PageSectionCard from "./ui/PageSectionCard";
import SummaryStat from "./ui/SummaryStat";
import StatusDot from "./ui/StatusDot";
import RoleEditor from "./RoleEditor";
import { useI18n } from "../i18n";
import { roleListChangeKey } from "../utils/pollingKeys";
interface RoleStatusProps {
workspace: string;
}
function getRoleState(role: RoleInfo, copy: ReturnType<typeof useI18n>["copy"]) {
if (role.pending > 0) {
return {
label: copy.roleStatus.states.attention,
dotClassName: "bg-[color:var(--app-accent-warm)]",
textClassName: "text-[color:var(--app-accent-warm)]",
};
}
if (role.session) {
return {
label: copy.roleStatus.states.active,
dotClassName: "app-dot-success",
textClassName: "app-text-success",
};
}
return {
label: copy.roleStatus.states.idle,
dotClassName: "app-dot-idle",
textClassName: "app-text-idle",
};
}
function getRolePriority(role: RoleInfo) {
return (role.pending > 0 ? 2 : 0) + (role.session ? 1 : 0);
}
export default function RoleStatus({ workspace }: RoleStatusProps) {
const { copy, formatAbsoluteDateTime, formatRelativeTime } = useI18n();
const fetcher = useCallback(() => fetchRoles(workspace), [workspace]);
const { data, loading, error, refresh } = usePolling<RoleInfo[]>(
fetcher,
5000,
{ getChangeKey: roleListChangeKey },
);
// Editor state: string = editing role name, undefined = closed
const [editorTarget, setEditorTarget] = useState<string | undefined>();
const sortedRoles = useMemo(() => {
if (!data) return [];
return [...data].sort((left, right) => {
if (left.sort_order !== right.sort_order) {
return left.sort_order - right.sort_order;
}
const priorityDiff = getRolePriority(right) - getRolePriority(left);
if (priorityDiff !== 0) {
return priorityDiff;
}
return left.name.localeCompare(right.name);
});
}, [data]);
const activeSessions = data?.filter((role) => role.session).length ?? 0;
const attentionCount = data?.filter((role) => role.pending > 0).length ?? 0;
const idleCount = data ? data.length - activeSessions : 0;
const waitingMessages = data?.reduce((sum, role) => sum + role.pending, 0) ?? 0;
return (
<AsyncPageState
workspace={workspace}
workspaceSubject={copy.roleStatus.workspaceSubject}
loading={loading}
loadingMode="initial"
hasData={Boolean(data && data.length > 0)}
error={error}
loadingEyebrow={copy.roleStatus.eyebrow}
loadingTitle={copy.roleStatus.loadingTitle}
errorEyebrow={copy.roleStatus.eyebrow}
errorTitle={copy.roleStatus.errorTitle}
emptyEyebrow={copy.roleStatus.eyebrow}
emptyTitle={copy.roleStatus.emptyTitle}
emptyDetail={copy.roleStatus.emptyDetail}
retryLabel={copy.common.retry}
onRetry={refresh}
>
<div className="space-y-4">
<PageHero
eyebrow={copy.roleStatus.eyebrow}
title={copy.roleStatus.heroTitle}
stats={
<div className="grid gap-4 sm:grid-cols-2 lg:min-w-[24rem]">
<SummaryStat
label={copy.roleStatus.summary.activeAgents}
value={activeSessions}
detail={copy.roleStatus.summary.activeAgentsDetail}
/>
<SummaryStat
label={copy.roleStatus.summary.needsAttention}
value={attentionCount}
detail={copy.roleStatus.summary.needsAttentionDetail}
tone="attention"
/>
<SummaryStat
label={copy.roleStatus.summary.waitingItems}
value={waitingMessages}
detail={copy.roleStatus.summary.waitingItemsDetail}
/>
<SummaryStat
label={copy.roleStatus.summary.notStarted}
value={idleCount}
detail={copy.roleStatus.summary.notStartedDetail}
tone="muted"
/>
</div>
}
/>
<PageSectionCard
title={copy.roleStatus.listTitle}
detail={copy.roleStatus.listDetail}
action={<Chip size="sm">{sortedRoles.length}</Chip>}
className="rounded-[26px]"
headerClassName="px-4 py-4 sm:px-5"
bodyClassName="grid gap-3 p-4 sm:p-5 md:grid-cols-2"
>
{sortedRoles.map((role) => {
const state = getRoleState(role, copy);
return (
<article
key={role.name}
className="app-panel-muted flex h-full flex-col rounded-2xl border border-[color:var(--app-divider-soft)] p-4"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<RoleBadge role={role.name} size="md" />
<StatusDot className={`h-2 w-2 ${state.dotClassName}`} />
<span className={`app-caption-medium ${state.textClassName}`}>
{state.label}
</span>
{role.pending > 0 && (
<Chip tone="attention" className="app-caption-medium">
{copy.roleStatus.states.waiting(role.pending)}
</Chip>
)}
</div>
<p
className="app-text-soft mt-3 text-sm"
title={role.description}
dir="auto"
>
{copy.roleStatus.conciseDescription[role.name as keyof typeof copy.roleStatus.conciseDescription] ?? role.description}
</p>
</div>
<Button
variant="ghost"
size="xs"
onClick={() => setEditorTarget(role.name)}
>
{copy.common.edit}
</Button>
</div>
{role.session?.last_message ? (
<p
className="app-text-faint mt-3 line-clamp-3 break-words text-xs leading-6"
title={role.session.last_message}
dir="auto"
>
{role.session.last_message}
</p>
) : null}
<div className="mt-auto flex items-end justify-between gap-3 pt-4">
<div className="min-w-0">
<div className="app-text-faint app-caption">{copy.roleStatus.lastActive}</div>
{role.session ? (
<div
className="app-text-primary mt-1 text-sm"
title={formatAbsoluteDateTime(role.session.last_used_at)}
>
{formatRelativeTime(role.session.last_used_at)}
</div>
) : (
<div className="app-text-idle mt-1 text-sm">{copy.common.notStarted}</div>
)}
</div>
<div className="app-text-faint text-right text-[11px] font-medium uppercase tracking-[0.16em]">
#{role.sort_order}
</div>
</div>
</article>
);
})}
</PageSectionCard>
{/* Role Editor slide-out panel */}
{editorTarget !== undefined && (
<RoleEditor
workspace={workspace}
roleName={editorTarget}
onClose={() => setEditorTarget(undefined)}
onSaved={() => {
setEditorTarget(undefined);
refresh();
}}
/>
)}
</div>
</AsyncPageState>
);
}