feat(case-ui): agents tab — full 18i (rich roster + surfaced pending forms) #383

Merged
chaim merged 1 commits from worktree-agents-full-18i into main 2026-06-30 19:37:25 +00:00
Showing only changes of commit bb83ddc611 - Show all commits

View File

@@ -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>
)}