feat(case-ui): agents tab — full 18i (rich roster + surfaced pending forms) #383
@@ -5,7 +5,6 @@ import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Markdown } from "@/components/ui/markdown";
|
||||
import { AgentStatusWidget } from "@/components/cases/agent-status-widget";
|
||||
import {
|
||||
useAgentActivity,
|
||||
useSendComment,
|
||||
@@ -17,6 +16,7 @@ import type {
|
||||
InteractionQuestion,
|
||||
InteractionTask,
|
||||
PaperclipComment,
|
||||
PaperclipAgentStatus,
|
||||
} from "@/lib/api/agents";
|
||||
import { formatRelative } from "@/lib/format-date";
|
||||
import { toast } from "sonner";
|
||||
@@ -82,6 +82,104 @@ function issueStatusLabel(status: string) {
|
||||
return ISSUE_STATUS_LABELS[status] ?? status;
|
||||
}
|
||||
|
||||
/* ── Agent roster (mockup 18i) — who's doing what right now ────── */
|
||||
|
||||
const AGENT_STATUS_LABEL: Record<string, string> = {
|
||||
active: "פעיל",
|
||||
running: "פעיל",
|
||||
idle: "ממתין",
|
||||
error: "שגיאה",
|
||||
};
|
||||
|
||||
function agentIsActive(s: string) {
|
||||
return s === "active" || s === "running";
|
||||
}
|
||||
|
||||
function agentStatusDot(s: string) {
|
||||
return agentIsActive(s)
|
||||
? "bg-success shadow-[0_0_6px_rgba(74,124,89,0.6)]"
|
||||
: s === "error"
|
||||
? "bg-danger"
|
||||
: "bg-rule";
|
||||
}
|
||||
|
||||
function agentStatusTone(s: string) {
|
||||
return agentIsActive(s)
|
||||
? "bg-success-bg text-success"
|
||||
: s === "error"
|
||||
? "bg-danger-bg text-danger"
|
||||
: "bg-rule-soft text-ink-muted";
|
||||
}
|
||||
|
||||
function AgentRoster({
|
||||
agents,
|
||||
comments,
|
||||
}: {
|
||||
agents: PaperclipAgentStatus[];
|
||||
comments: PaperclipComment[];
|
||||
}) {
|
||||
if (!agents.length) return null;
|
||||
|
||||
// "current activity" per agent = its most recent comment body (by name/role).
|
||||
const latest = new Map<string, string>();
|
||||
for (const a of agents) {
|
||||
let best: PaperclipComment | null = null;
|
||||
let bestAt = -1;
|
||||
for (const c of comments) {
|
||||
const mine =
|
||||
(c.agent_name && c.agent_name === a.name) ||
|
||||
(c.agent_role && c.agent_role === a.role);
|
||||
if (!mine) continue;
|
||||
const at = c.created_at ? new Date(c.created_at).getTime() : 0;
|
||||
if (at >= bestAt) {
|
||||
bestAt = at;
|
||||
best = c;
|
||||
}
|
||||
}
|
||||
if (best) latest.set(a.id, best.body);
|
||||
}
|
||||
|
||||
const activeCount = agents.filter((a) => agentIsActive(a.status)).length;
|
||||
|
||||
return (
|
||||
<div className="mb-3 rounded-lg border border-rule bg-parchment/40 p-3">
|
||||
<div className="flex items-center gap-2 mb-2.5">
|
||||
<Bot className="w-4 h-4 text-gold-deep shrink-0" />
|
||||
<h3 className="text-[0.82rem] font-bold text-navy">סוכני התיק</h3>
|
||||
<span className="text-[0.72rem] text-ink-muted">
|
||||
<b className="text-success font-bold">{activeCount} פעילים</b> מתוך {agents.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5">
|
||||
{agents.map((a) => {
|
||||
const active = agentIsActive(a.status);
|
||||
const activity = latest.get(a.id);
|
||||
return (
|
||||
<div
|
||||
key={a.id}
|
||||
className={`rounded-lg border p-2.5 ${active ? "border-success/40 bg-success-bg/40" : "border-rule bg-surface"}`}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={`w-2 h-2 rounded-full shrink-0 ${agentStatusDot(a.status)}`} />
|
||||
<span className="text-[0.78rem] font-bold text-navy truncate">{a.name}</span>
|
||||
<span
|
||||
className={`ms-auto text-[0.6rem] font-bold rounded-full px-1.5 py-0.5 whitespace-nowrap ${agentStatusTone(a.status)}`}
|
||||
>
|
||||
{AGENT_STATUS_LABEL[a.status] ?? a.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[0.66rem] text-ink-muted mt-0.5">{roleLabel(a.role)}</div>
|
||||
<div className="text-[0.7rem] text-ink-soft mt-1.5 line-clamp-2 leading-tight">
|
||||
{activity || "—"}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Time formatting ─────────────────────────────────────────── */
|
||||
|
||||
function timeAgo(iso: string | null): string {
|
||||
@@ -690,7 +788,9 @@ export function AgentActivityFeed({
|
||||
|
||||
const comments = data.comments ?? [];
|
||||
const interactions = data.interactions ?? [];
|
||||
const pendingCount = interactions.filter((i) => i.status === "pending").length;
|
||||
// Pending interactions are surfaced at the top (awaiting-you, mockup 18i) and
|
||||
// NOT repeated in the timeline below.
|
||||
const pending = interactions.filter((i) => i.status === "pending");
|
||||
|
||||
// Unified, time-sorted feed: comments + interactions interleaved.
|
||||
type FeedItem =
|
||||
@@ -703,11 +803,13 @@ export function AgentActivityFeed({
|
||||
at: c.created_at ? new Date(c.created_at).getTime() : 0,
|
||||
comment: c,
|
||||
})),
|
||||
...interactions.map<FeedItem>((i) => ({
|
||||
kind: "interaction",
|
||||
at: i.created_at ? new Date(i.created_at).getTime() : 0,
|
||||
interaction: i,
|
||||
})),
|
||||
...interactions
|
||||
.filter((i) => i.status !== "pending")
|
||||
.map<FeedItem>((i) => ({
|
||||
kind: "interaction",
|
||||
at: i.created_at ? new Date(i.created_at).getTime() : 0,
|
||||
interaction: i,
|
||||
})),
|
||||
].sort((a, b) => a.at - b.at);
|
||||
|
||||
// An issue is "active" if it's not done/cancelled. When everything is closed
|
||||
@@ -719,15 +821,27 @@ export function AgentActivityFeed({
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* agent roster — who's doing what right now (mockup 18i) */}
|
||||
<div className="mb-3 rounded-lg border border-rule bg-parchment/40 px-3 py-2.5">
|
||||
<AgentStatusWidget caseNumber={caseNumber} />
|
||||
</div>
|
||||
<AgentRoster agents={data.agents ?? []} comments={comments} />
|
||||
|
||||
{/* pending interactions surfaced to the top (mockup 18i — awaiting you) */}
|
||||
{pendingCount > 0 && (
|
||||
<div className="mb-3 rounded-lg border border-amber-300 bg-amber-50 px-4 py-2.5 text-[0.82rem] font-semibold text-amber-800 flex items-center gap-2">
|
||||
<Clock className="w-4 h-4" />
|
||||
ממתין לתשובתך — {pendingCount} {pendingCount === 1 ? "בקשה" : "בקשות"} בהמשך הזרם
|
||||
{/* pending interactions surfaced to the top — the actual forms, not a
|
||||
pointer (mockup 18i — awaiting you). Excluded from the timeline. */}
|
||||
{pending.length > 0 && (
|
||||
<div className="mb-3 rounded-lg border border-amber-300 bg-amber-50 p-3 space-y-2">
|
||||
<div className="flex items-center gap-2 text-[0.82rem] font-bold text-amber-800">
|
||||
<Clock className="w-4 h-4" />
|
||||
ממתין לתשובתך
|
||||
<span className="text-[0.7rem] font-semibold bg-amber-100 rounded-full px-2 py-0.5">
|
||||
{pending.length}
|
||||
</span>
|
||||
</div>
|
||||
{pending.map((i) => (
|
||||
<InteractionCard
|
||||
key={`pending-${i.id}`}
|
||||
interaction={i}
|
||||
caseNumber={caseNumber}
|
||||
issueMap={issueMap}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user