feat(case-ui): agents tab — full 18i (rich roster + surfaced pending forms)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 11s

Completes mockup 18i beyond the earlier minimal version:
- AgentRoster — a 5-up grid of agents, each with a status dot, name, role label
  and its current activity (derived from the agent's most recent comment).
  Replaces the simple AgentStatusWidget list at the top of the feed (reset still
  lives in the banner status-rail popover).
- "ממתין לתשובתך" now renders the actual pending InteractionCards (the
  approve/reject/answer forms) at the top, not just a count pointer — and those
  pending interactions are excluded from the timeline below so they aren't shown
  twice.

All interaction logic / mutations preserved. tsc --noEmit + eslint clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-30 19:37:01 +00:00
parent d1ca1152b1
commit bb83ddc611

View File

@@ -5,7 +5,6 @@ import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Markdown } from "@/components/ui/markdown"; import { Markdown } from "@/components/ui/markdown";
import { AgentStatusWidget } from "@/components/cases/agent-status-widget";
import { import {
useAgentActivity, useAgentActivity,
useSendComment, useSendComment,
@@ -17,6 +16,7 @@ import type {
InteractionQuestion, InteractionQuestion,
InteractionTask, InteractionTask,
PaperclipComment, PaperclipComment,
PaperclipAgentStatus,
} from "@/lib/api/agents"; } from "@/lib/api/agents";
import { formatRelative } from "@/lib/format-date"; import { formatRelative } from "@/lib/format-date";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -82,6 +82,104 @@ function issueStatusLabel(status: string) {
return ISSUE_STATUS_LABELS[status] ?? status; 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 ─────────────────────────────────────────── */ /* ── Time formatting ─────────────────────────────────────────── */
function timeAgo(iso: string | null): string { function timeAgo(iso: string | null): string {
@@ -690,7 +788,9 @@ export function AgentActivityFeed({
const comments = data.comments ?? []; const comments = data.comments ?? [];
const interactions = data.interactions ?? []; 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. // Unified, time-sorted feed: comments + interactions interleaved.
type FeedItem = type FeedItem =
@@ -703,11 +803,13 @@ export function AgentActivityFeed({
at: c.created_at ? new Date(c.created_at).getTime() : 0, at: c.created_at ? new Date(c.created_at).getTime() : 0,
comment: c, comment: c,
})), })),
...interactions.map<FeedItem>((i) => ({ ...interactions
kind: "interaction", .filter((i) => i.status !== "pending")
at: i.created_at ? new Date(i.created_at).getTime() : 0, .map<FeedItem>((i) => ({
interaction: i, kind: "interaction",
})), at: i.created_at ? new Date(i.created_at).getTime() : 0,
interaction: i,
})),
].sort((a, b) => a.at - b.at); ].sort((a, b) => a.at - b.at);
// An issue is "active" if it's not done/cancelled. When everything is closed // An issue is "active" if it's not done/cancelled. When everything is closed
@@ -719,15 +821,27 @@ export function AgentActivityFeed({
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
{/* agent roster — who's doing what right now (mockup 18i) */} {/* 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"> <AgentRoster agents={data.agents ?? []} comments={comments} />
<AgentStatusWidget caseNumber={caseNumber} />
</div>
{/* pending interactions surfaced to the top (mockup 18i — awaiting you) */} {/* pending interactions surfaced to the top — the actual forms, not a
{pendingCount > 0 && ( pointer (mockup 18i — awaiting you). Excluded from the timeline. */}
<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"> {pending.length > 0 && (
<Clock className="w-4 h-4" /> <div className="mb-3 rounded-lg border border-amber-300 bg-amber-50 p-3 space-y-2">
ממתין לתשובתך {pendingCount} {pendingCount === 1 ? "בקשה" : "בקשות"} בהמשך הזרם <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> </div>
)} )}