229 lines
8.1 KiB
TypeScript
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>
|
|
);
|
|
}
|