import { useMemo, useState } from "react"; import { Link } from "@/lib/router"; import { Bot, User, Cog, ChevronDown, ListFilter } from "lucide-react"; import type { CaseEvent, CaseEventKind } from "@/api/cases"; import { Button } from "@/components/ui/button"; import { StatusIcon } from "@/components/StatusIcon"; import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { cn, relativeTime } from "@/lib/utils"; const EVENT_LABEL: Record = { created: "created", updated: "updated", fields_changed: "fields changed", status_changed: "status changed", issue_linked: "issue linked", issue_unlinked: "issue unlinked", document_revised: "document revised", child_linked: "child linked", attachment_added: "attachment added", label_added: "label added", label_removed: "label removed", handoff_requested: "handoff requested", handoff_applied: "task handed off", handoff_delivered: "agent wake queued", handoff_completed: "case completed", handoff_cancelled: "handoff cancelled", }; /** Human label for the actor, preferring the resolved agent name. */ function actorLabel(event: CaseEvent): string { if (event.actorType === "agent") return event.actorAgentName ?? "Agent"; if (event.actorType === "user") return "User"; return "System"; } function ActorIcon({ event }: { event: CaseEvent }) { const Icon = event.actorType === "agent" ? Bot : event.actorType === "user" ? User : Cog; return ; } function issueRelationLabel(event: CaseEvent): string { return event.kind === "issue_linked" || event.kind === "issue_unlinked" ? "issue" : "via"; } /** One event with actor + run→issue attribution (P4 §1). */ export function CaseEventRow({ event, compact = false }: { event: CaseEvent; compact?: boolean }) { const detail = event.kind === "status_changed" && event.payload ? `${(event.payload.previousStatus as string) ?? "?"} → ${(event.payload.status as string) ?? "?"}` : ""; return (
{EVENT_LABEL[event.kind] ?? event.kind} {detail && · {detail}}
{actorLabel(event)} {event.issue && ( <> · {issueRelationLabel(event)} {event.issue.identifier} {event.issue.title} )} · {relativeTime(event.createdAt)}
); } /** The full activity feed with kind filters (detail-page Activity tab). */ export function CaseActivityFeed({ events }: { events: CaseEvent[] }) { const [active, setActive] = useState>(new Set()); // Only offer filters for kinds actually present, in first-seen order. const presentKinds = useMemo(() => { const seen: CaseEventKind[] = []; for (const e of events) if (!seen.includes(e.kind)) seen.push(e.kind); return seen; }, [events]); const filtered = useMemo( () => (active.size === 0 ? events : events.filter((e) => active.has(e.kind))), [events, active], ); function toggle(kind: CaseEventKind) { setActive((prev) => { const next = new Set(prev); if (next.has(kind)) next.delete(kind); else next.add(kind); return next; }); } const filterLabel = active.size === 0 ? "All activity" : active.size === 1 ? EVENT_LABEL[[...active][0]!] ?? [...active][0]! : `${active.size} filters`; if (events.length === 0) { return

No activity yet.

; } return (

{filtered.length} of {events.length} events

Activity filter setActive(new Set())}> All activity {presentKinds.map((kind) => ( toggle(kind)} > {EVENT_LABEL[kind] ?? kind} ))}
{filtered.length === 0 ? (

No events match this filter.

) : (
{filtered.map((event) => ( ))}
)}
); }