Files
legal-ai/web-ui/src/components/cases/agent-activity-feed.tsx
Chaim cb4aa703ec
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 36s
Lint — undefined names / undefined-names (pull_request) Successful in 11s
feat(agents-panel): היררכיה, סמן-כהושלם ידני, ובורר-יעד נגלל (5 תיקוני יו"ר)
טאב הסוכנים בתיק — 5 תיקונים לבקשת היו"ר, מאושרים דרך שער-העיצוב
(Claude Design X17, כרטיס 18i4):

1. היררכיה — הרצה שנפתחה תחת השורש (למשל CMP-220 תחת CMP-189) מסומנת
   בתג "↳ <parent>" גם בציר-הזמן וגם בבורר-היעד (בהזחה). הנתון parent_id
   כבר הגיע מה-API — תצוגה בלבד.
2. סמן-כהושלם / בטל ידני — כל משימה פתוחה מקבלת פעולות בכותרת. פותר
   "אין אפשרות לסמן ידנית" (superseded runs נתקעו ב-in_review בלי מנגנון
   סגירה). endpoint חדש: POST /api/cases/{n}/agents/issue-status → דרך
   ה-Port (pc_set_issue_status), סגירה loop-safe ישירה ל-DB, בלי wakeup.
3. בורר-היעד נגלל — max-height + overflow פנימי, כך שהתיבה לא בורחת
   מגבולות העמוד. פותר "לא רואים את התחתית / לא מגיעים לפתיחת הליך".
4. "פתח הרצה חדשה" עלה לראש הרשימה, מעל "משימות פעילות", ומודגש.
5. קו-הפרדה בין משימות בציר-הזמן עבה יותר (border-b-2 rule).

G12: כל מגע-פלטפורמה עובר דרך agent_platform_port. אימות: lint + tsc +
next build עוברים.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 12:25:36 +00:00

1394 lines
48 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState, useMemo } from "react";
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 {
useAgentActivity,
useSendComment,
useSubmitInteraction,
useDismissInteraction,
useSetIssueStatus,
} from "@/lib/api/agents";
import type {
Interaction,
InteractionPayload,
InteractionQuestion,
InteractionTask,
PaperclipComment,
PaperclipAgentStatus,
PaperclipIssue,
} from "@/lib/api/agents";
import { formatRelative } from "@/lib/format-date";
import { toast } from "sonner";
import {
Bot,
User,
Send,
Loader2,
MessageSquare,
Clock,
CheckCircle2,
XCircle,
HelpCircle,
ChevronDown,
Ban,
Target,
Plus,
AlertTriangle,
} from "lucide-react";
/* ── Role → color mapping ────────────────────────────────────── */
const ROLE_COLORS: Record<string, string> = {
ceo: "bg-blue-100 text-blue-800 border-blue-200",
researcher: "bg-purple-100 text-purple-800 border-purple-200",
engineer: "bg-emerald-100 text-emerald-800 border-emerald-200",
qa: "bg-amber-100 text-amber-800 border-amber-200",
};
const ROLE_DOT: Record<string, string> = {
ceo: "bg-blue-500",
researcher: "bg-purple-500",
engineer: "bg-emerald-500",
qa: "bg-amber-500",
};
const ROLE_LABELS: Record<string, string> = {
ceo: "מנהל",
researcher: "חוקר",
engineer: "מהנדס",
qa: "בודק איכות",
general: "כללי",
};
const ISSUE_STATUS_LABELS: Record<string, string> = {
backlog: "ממתין",
todo: "לביצוע",
in_progress: "בביצוע",
in_review: "בבדיקה",
done: "הושלם",
cancelled: "בוטל",
blocked: "חסום",
};
function roleColor(role: string | null) {
return ROLE_COLORS[role ?? ""] ?? "bg-gray-100 text-gray-700 border-gray-200";
}
function roleDot(role: string | null) {
return ROLE_DOT[role ?? ""] ?? "bg-gray-400";
}
function roleLabel(role: string | null) {
return ROLE_LABELS[role ?? ""] ?? role ?? "";
}
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 }: { agents: PaperclipAgentStatus[] }) {
if (!agents.length) return null;
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>
{/* Compact roster — all agents on one row (mockup 18i v2): status · name ·
role only. The per-agent "current activity" line was removed: it
matched comments by role-fallback, so agents sharing a role showed the
same text. */}
<div className="grid gap-1.5 grid-cols-3 sm:grid-cols-5 lg:grid-cols-9">
{agents.map((a) => {
const active = agentIsActive(a.status);
return (
<div
key={a.id}
className={`rounded-lg border p-2 min-w-0 ${active ? "border-success/40 bg-success-bg/40" : "border-rule bg-surface"}`}
>
<div className="flex items-center gap-1.5 min-w-0">
<span className={`w-1.5 h-1.5 rounded-full shrink-0 ${agentStatusDot(a.status)}`} />
<span className="text-[0.72rem] font-bold text-navy truncate">{a.name}</span>
</div>
<div className="text-[0.6rem] text-ink-muted mt-0.5 truncate">{roleLabel(a.role)}</div>
<span
className={`inline-block mt-1 text-[0.55rem] 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>
</div>
);
}
/* ── Time formatting ─────────────────────────────────────────── */
function timeAgo(iso: string | null): string {
return formatRelative(iso, { units: "short", fallback: "" });
}
/* ── Issue identifier → find matching identifier ─────────────── */
function issueIdentifier(
comment: PaperclipComment,
issueMap: Map<string, string>,
): string {
return issueMap.get(comment.issue_id) ?? "";
}
/* ── Comment card ────────────────────────────────────────────── */
function CommentCard({
comment,
issueMap,
}: {
comment: PaperclipComment;
issueMap: Map<string, string>;
}) {
const isAgent = !!comment.author_agent_id;
const label = isAgent ? comment.agent_name ?? "סוכן" : "חיים";
const identifier = issueIdentifier(comment, issueMap);
return (
<div className="group relative flex gap-3 py-3 px-2 rounded-lg hover:bg-sand-soft/50 transition-colors">
{/* Avatar */}
<div className="flex-shrink-0 pt-0.5">
{isAgent ? (
<div
className={`w-8 h-8 rounded-full flex items-center justify-center ${roleColor(comment.agent_role)}`}
>
<Bot className="w-4 h-4" />
</div>
) : (
<div className="w-8 h-8 rounded-full flex items-center justify-center bg-gold-soft text-gold-deep border border-gold">
<User className="w-4 h-4" />
</div>
)}
</div>
{/* Content */}
<div className="flex-1 min-w-0">
{/* Header */}
<div className="flex items-center gap-2 mb-1 flex-wrap">
<span className="text-sm font-semibold text-navy">{label}</span>
{isAgent && comment.agent_role && (
<span
className={`inline-flex items-center gap-1 text-[11px] px-1.5 py-0.5 rounded-full border ${roleColor(comment.agent_role)}`}
>
<span className={`w-1.5 h-1.5 rounded-full ${roleDot(comment.agent_role)}`} />
{roleLabel(comment.agent_role)}
</span>
)}
{identifier && (
<Badge variant="outline" className="text-[10px] font-mono">
{identifier}
</Badge>
)}
<span className="text-[11px] text-ink-faint me-auto flex items-center gap-1">
<Clock className="w-3 h-3" />
{timeAgo(comment.created_at)}
</span>
</div>
{/* Body */}
<div className="text-sm">
<Markdown content={comment.body} />
</div>
</div>
</div>
);
}
/* ── Interaction card ────────────────────────────────────────── */
const RESOLVED_LABELS: Record<string, { text: string; tone: string; Icon: typeof CheckCircle2 }> = {
answered: { text: "נענה", tone: "text-emerald-700 bg-emerald-50 border-emerald-200", Icon: CheckCircle2 },
accepted: { text: "התקבל", tone: "text-emerald-700 bg-emerald-50 border-emerald-200", Icon: CheckCircle2 },
rejected: { text: "נדחה", tone: "text-rose-700 bg-rose-50 border-rose-200", Icon: XCircle },
expired: { text: "פג תוקף", tone: "text-ink-faint bg-gray-50 border-gray-200", Icon: XCircle },
failed: { text: "כשל", tone: "text-rose-700 bg-rose-50 border-rose-200", Icon: XCircle },
};
function ResolvedBadge({ status }: { status: string }) {
const meta = RESOLVED_LABELS[status];
if (!meta) return null;
const { text, tone, Icon } = meta;
return (
<span className={`inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-full border ${tone}`}>
<Icon className="w-3 h-3" />
{text}
</span>
);
}
function summaryAnswer(interaction: Interaction): string | null {
const result = interaction.result;
if (!result) return null;
if (typeof result.summaryMarkdown === "string" && result.summaryMarkdown.trim()) {
return result.summaryMarkdown;
}
if (interaction.kind === "ask_user_questions" && Array.isArray(result.answers)) {
const optionLabel = (qid: string, oid: string): string => {
const q = interaction.payload.questions?.find((qq) => qq.id === qid);
return q?.options.find((o) => o.id === oid)?.label ?? oid;
};
return (result.answers as Array<{ questionId: string; optionIds: string[] }>)
.map((a) =>
`**${interaction.payload.questions?.find((q) => q.id === a.questionId)?.prompt ?? a.questionId}** — ${a.optionIds
.map((oid) => optionLabel(a.questionId, oid))
.join(", ")}`,
)
.join("\n\n");
}
if (interaction.kind === "request_confirmation" && typeof result.reason === "string" && result.reason) {
return `נימוק: ${result.reason}`;
}
if (interaction.kind === "suggest_tasks") {
const created = Array.isArray(result.createdTasks) ? result.createdTasks.length : 0;
const skipped = Array.isArray(result.skippedClientKeys) ? result.skippedClientKeys.length : 0;
if (created || skipped) {
const parts: string[] = [];
if (created) parts.push(`נוצרו ${created} משימות`);
if (skipped) parts.push(`דילוג על ${skipped}`);
return parts.join(" · ");
}
}
return null;
}
function AskUserQuestionsForm({
interaction,
onSubmit,
pending,
}: {
interaction: Interaction;
onSubmit: (answers: Array<{ questionId: string; optionIds: string[] }>) => void;
pending: boolean;
}) {
const questions: InteractionQuestion[] = interaction.payload.questions ?? [];
const [selections, setSelections] = useState<Record<string, string[]>>({});
const setSingle = (qid: string, oid: string) =>
setSelections((prev) => ({ ...prev, [qid]: [oid] }));
const toggleMulti = (qid: string, oid: string) =>
setSelections((prev) => {
const cur = prev[qid] ?? [];
return {
...prev,
[qid]: cur.includes(oid) ? cur.filter((x) => x !== oid) : [...cur, oid],
};
});
const missingRequired = questions.some(
(q) => (q.required ?? true) && !(selections[q.id]?.length),
);
const handleSend = () => {
const answers = questions
.map((q) => ({ questionId: q.id, optionIds: selections[q.id] ?? [] }))
.filter((a) => a.optionIds.length > 0);
onSubmit(answers);
};
return (
<div className="space-y-4">
{questions.map((q) => {
const isSingle = (q.selectionMode ?? "single") === "single";
const chosen = selections[q.id] ?? [];
return (
<fieldset key={q.id} className="space-y-2">
<legend className="text-sm font-semibold text-navy mb-1">
{q.prompt}
{(q.required ?? true) && <span className="text-rose-600 ms-1">*</span>}
</legend>
<div className="space-y-1.5">
{q.options.map((opt) => {
const checked = chosen.includes(opt.id);
return (
<label
key={opt.id}
className={`flex items-start gap-2 cursor-pointer rounded-md border p-2 transition-colors ${
checked
? "border-navy bg-navy/5"
: "border-rule hover:bg-sand-soft/60"
}`}
>
<input
type={isSingle ? "radio" : "checkbox"}
name={q.id}
value={opt.id}
checked={checked}
onChange={() =>
isSingle ? setSingle(q.id, opt.id) : toggleMulti(q.id, opt.id)
}
className="mt-1 accent-navy"
disabled={pending}
/>
<span className="flex-1 text-sm">
<span className="font-medium text-navy">{opt.label}</span>
{opt.description && (
<span className="block text-xs text-ink-faint mt-0.5">
{opt.description}
</span>
)}
</span>
</label>
);
})}
</div>
</fieldset>
);
})}
<div className="flex justify-end">
<Button
size="sm"
onClick={handleSend}
disabled={pending || missingRequired}
>
{pending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Send className="w-4 h-4 me-1" />
)}
{interaction.payload.submitLabel || "שלח תשובה"}
</Button>
</div>
</div>
);
}
function RequestConfirmationForm({
interaction,
onAccept,
onReject,
pending,
}: {
interaction: Interaction;
onAccept: () => void;
onReject: (reason: string) => void;
pending: boolean;
}) {
const payload = interaction.payload;
const allowReason = payload.allowDeclineReason !== false;
const requireReason = payload.rejectRequiresReason === true;
const [showReason, setShowReason] = useState(requireReason);
const [reason, setReason] = useState("");
const acceptLabel = (payload.acceptLabel as string) || "אישור";
const rejectLabel = (payload.rejectLabel as string) || "דחייה";
const reasonLabel =
(payload.rejectReasonLabel as string) || "נימוק (לא חובה)";
const reasonPlaceholder =
(payload.declineReasonPlaceholder as string) || "סיבת הדחייה...";
const handleReject = () => {
if (requireReason && !reason.trim()) {
setShowReason(true);
return;
}
onReject(reason.trim());
};
return (
<div className="space-y-3">
{typeof payload.prompt === "string" && (
<div className="text-sm text-navy whitespace-pre-line">{payload.prompt}</div>
)}
{typeof payload.detailsMarkdown === "string" && payload.detailsMarkdown && (
<div className="text-sm bg-sand-soft/40 rounded-md p-2">
<Markdown content={payload.detailsMarkdown} />
</div>
)}
{showReason && allowReason && (
<div className="space-y-1">
<label className="text-xs font-medium text-ink-faint">{reasonLabel}</label>
<Textarea
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder={reasonPlaceholder}
className="min-h-[60px] text-sm"
dir="rtl"
disabled={pending}
/>
</div>
)}
<div className="flex flex-wrap gap-2 justify-end">
{allowReason && !showReason && (
<Button
size="sm"
variant="outline"
onClick={() => setShowReason(true)}
disabled={pending}
>
הוסף נימוק
</Button>
)}
<Button
size="sm"
variant="outline"
onClick={handleReject}
disabled={pending || (requireReason && !reason.trim())}
>
<XCircle className="w-4 h-4 me-1" />
{rejectLabel}
</Button>
<Button size="sm" onClick={onAccept} disabled={pending}>
{pending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<CheckCircle2 className="w-4 h-4 me-1" />
)}
{acceptLabel}
</Button>
</div>
</div>
);
}
function SuggestTasksForm({
interaction,
onAccept,
onReject,
pending,
}: {
interaction: Interaction;
onAccept: (selectedClientKeys: string[]) => void;
onReject: (reason: string) => void;
pending: boolean;
}) {
const tasks: InteractionTask[] = (interaction.payload.tasks as InteractionTask[]) ?? [];
const [selected, setSelected] = useState<Set<string>>(
() => new Set(tasks.map((t) => t.clientKey)),
);
const [showReason, setShowReason] = useState(false);
const [reason, setReason] = useState("");
const toggle = (key: string) =>
setSelected((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
return (
<div className="space-y-3">
<div className="space-y-1.5 max-h-[260px] overflow-y-auto">
{tasks.map((t) => {
const checked = selected.has(t.clientKey);
return (
<label
key={t.clientKey}
className={`flex items-start gap-2 cursor-pointer rounded-md border p-2 ${
checked
? "border-navy bg-navy/5"
: "border-rule hover:bg-sand-soft/60"
}`}
>
<input
type="checkbox"
checked={checked}
onChange={() => toggle(t.clientKey)}
className="mt-1 accent-navy"
disabled={pending}
/>
<span className="flex-1 text-sm">
<span className="font-medium text-navy">{t.title}</span>
{t.description && (
<span className="block text-xs text-ink-faint mt-0.5 whitespace-pre-line">
{t.description}
</span>
)}
</span>
</label>
);
})}
</div>
{showReason && (
<Textarea
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="סיבת הדחייה (לא חובה)..."
className="min-h-[60px] text-sm"
dir="rtl"
disabled={pending}
/>
)}
<div className="flex flex-wrap gap-2 justify-end">
<Button
size="sm"
variant="outline"
onClick={() => (showReason ? onReject(reason.trim()) : setShowReason(true))}
disabled={pending}
>
<XCircle className="w-4 h-4 me-1" />
{showReason ? "אישור דחייה" : "דחייה"}
</Button>
<Button
size="sm"
onClick={() => onAccept(Array.from(selected))}
disabled={pending || selected.size === 0}
>
{pending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<CheckCircle2 className="w-4 h-4 me-1" />
)}
אישור משימות נבחרות ({selected.size})
</Button>
</div>
</div>
);
}
function InteractionCard({
interaction,
caseNumber,
issueMap,
defaultOpen = true,
}: {
interaction: Interaction;
caseNumber: string;
issueMap: Map<string, string>;
defaultOpen?: boolean;
}) {
const submit = useSubmitInteraction(caseNumber);
const dismiss = useDismissInteraction(caseNumber);
const identifier = issueMap.get(interaction.issue_id) ?? "";
const isPending = interaction.status === "pending";
const summary = summaryAnswer(interaction);
// Pending interactions collapse into an accordion (mockup 18i v2); resolved
// ones always render their one-line summary.
const [open, setOpen] = useState(defaultOpen);
const send = (action: "respond" | "accept" | "reject", payload: InteractionPayload | Record<string, unknown>) => {
submit.mutate(
{
issue_id: interaction.issue_id,
interaction_id: interaction.id,
action,
payload: payload as Record<string, unknown>,
},
{
onSuccess: () => toast.success("התשובה נשלחה"),
onError: () => toast.error("שגיאה בשליחת התשובה"),
},
);
};
const handleDismiss = () => {
dismiss.mutate(
{ issue_id: interaction.issue_id, interaction_id: interaction.id },
{
onSuccess: () => toast.success("הבקשה בוטלה"),
onError: () => toast.error("שגיאה בביטול הבקשה"),
},
);
};
return (
<div
className={`group relative flex gap-3 py-3 px-2 rounded-lg border transition-colors ${
isPending
? "border-amber-300 bg-amber-50/40"
: "border-rule bg-sand-soft/30"
}`}
>
<div className="flex-shrink-0 pt-0.5">
<div
className={`w-8 h-8 rounded-full flex items-center justify-center ${
isPending
? "bg-amber-100 text-amber-800 border border-amber-300"
: "bg-emerald-100 text-emerald-800 border border-emerald-200"
}`}
>
<HelpCircle className="w-4 h-4" />
</div>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2 flex-wrap">
{isPending ? (
<button
type="button"
onClick={() => setOpen((o) => !o)}
aria-expanded={open}
className="flex items-center gap-2 flex-wrap text-start min-w-0"
>
<ChevronDown
className={`w-4 h-4 text-ink-faint shrink-0 transition-transform ${open ? "rotate-180" : ""}`}
/>
<span className="text-sm font-semibold text-navy">
{interaction.title || "שאלה לסוכן"}
</span>
<span className="inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-full border border-amber-300 bg-amber-100 text-amber-800">
ממתין לתשובה
</span>
</button>
) : (
<>
<span className="text-sm font-semibold text-navy">
{interaction.title || "שאלה לסוכן"}
</span>
<ResolvedBadge status={interaction.status} />
</>
)}
{identifier && (
<Badge variant="outline" className="text-[10px] font-mono">
{identifier}
</Badge>
)}
<span className="text-[11px] text-ink-faint me-auto flex items-center gap-1">
<Clock className="w-3 h-3" />
{timeAgo(interaction.resolved_at ?? interaction.created_at)}
</span>
{isPending && (
<Button
size="sm"
variant="ghost"
onClick={handleDismiss}
disabled={dismiss.isPending}
className="h-7 px-2 text-ink-muted hover:text-danger"
title="בטל בקשה כפולה/מיושנת — ללא העברה לסוכן"
>
{dismiss.isPending ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Ban className="w-3.5 h-3.5 me-1" />
)}
התעלם
</Button>
)}
</div>
{interaction.summary && (!isPending || open) && (
<div className="text-xs text-ink-faint mb-2">{interaction.summary}</div>
)}
{isPending ? (
open ? (
interaction.kind === "ask_user_questions" ? (
<AskUserQuestionsForm
interaction={interaction}
onSubmit={(answers) => send("respond", { answers })}
pending={submit.isPending}
/>
) : interaction.kind === "request_confirmation" ? (
<RequestConfirmationForm
interaction={interaction}
onAccept={() => send("accept", {})}
onReject={(reason) =>
send("reject", reason ? { reason } : {})
}
pending={submit.isPending}
/>
) : interaction.kind === "suggest_tasks" ? (
<SuggestTasksForm
interaction={interaction}
onAccept={(keys) =>
send("accept", keys.length ? { selectedClientKeys: keys } : {})
}
onReject={(reason) =>
send("reject", reason ? { reason } : {})
}
pending={submit.isPending}
/>
) : null
) : null
) : summary ? (
<div className="text-sm">
<Markdown content={summary} />
</div>
) : null}
</div>
</div>
);
}
/* ── Issue group (timeline grouped by issue, accordion) ──────── */
type FeedItem =
| { kind: "comment"; at: number; comment: PaperclipComment }
| { kind: "interaction"; at: number; interaction: Interaction };
/** Drop the "[ערר 1043-02-26] " prefix Paperclip puts on every issue title. */
function shortIssueTitle(title: string): string {
return title.replace(/^\s*\[[^\]]*\]\s*/, "").trim() || title;
}
function IssueGroup({
issue,
items,
caseNumber,
issueMap,
defaultOpen,
}: {
issue: PaperclipIssue;
items: FeedItem[];
caseNumber: string;
issueMap: Map<string, string>;
defaultOpen: boolean;
}) {
const [open, setOpen] = useState(defaultOpen);
const setStatus = useSetIssueStatus(caseNumber);
const closed = issue.status === "done" || issue.status === "cancelled";
// Show the parent run this issue hangs under (e.g. a "הרצה חדשה" nested under
// the live CEO root) so the hierarchy is visible, not flattened.
const parentIdentifier = issue.parent_id
? issueMap.get(issue.parent_id)
: undefined;
const close = (status: "done" | "cancelled") =>
setStatus.mutate(
{ issue_id: issue.id, status },
{
onSuccess: () =>
toast.success(
status === "done" ? "המשימה סומנה כהושלמה" : "המשימה בוטלה",
),
onError: () => toast.error("שגיאה בעדכון סטטוס המשימה"),
},
);
return (
<div className="border-b-2 border-rule last:border-b-0">
<div className="w-full flex items-center gap-2 px-2 py-2.5 hover:bg-sand-soft/50">
<button
type="button"
onClick={() => setOpen((o) => !o)}
aria-expanded={open}
className="flex-1 flex items-center gap-2 text-start min-w-0"
>
<Badge
variant={closed ? "secondary" : "default"}
className="text-[10px] font-mono shrink-0"
>
{issue.identifier}
</Badge>
<span className="text-[0.78rem] font-semibold text-navy truncate">
{shortIssueTitle(issue.title)}
</span>
{parentIdentifier && (
<span
className="inline-flex items-center gap-0.5 text-[0.6rem] font-bold text-gold-deep bg-gold/10 border border-rule rounded-full px-1.5 py-0.5 shrink-0 whitespace-nowrap"
title={`תת-משימה של ${parentIdentifier}`}
>
{parentIdentifier}
</span>
)}
<span className="text-[0.7rem] text-ink-muted whitespace-nowrap">
{issueStatusLabel(issue.status)} · {items.length}
</span>
</button>
{!closed && (
<div className="flex items-center gap-1 shrink-0">
<Button
size="sm"
variant="outline"
onClick={() => close("done")}
disabled={setStatus.isPending}
className="h-7 px-2 text-[0.7rem] text-success border-success/40 hover:bg-success-bg"
title="סמן את המשימה כהושלמה"
>
{setStatus.isPending ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<CheckCircle2 className="w-3.5 h-3.5 me-1" />
)}
הושלם
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => close("cancelled")}
disabled={setStatus.isPending}
className="h-7 px-2 text-[0.7rem] text-ink-muted hover:text-danger"
title="בטל את המשימה"
>
בטל
</Button>
</div>
)}
<button
type="button"
onClick={() => setOpen((o) => !o)}
aria-label={open ? "כווץ" : "הרחב"}
className="shrink-0"
>
<ChevronDown
className={`w-4 h-4 text-ink-faint transition-transform ${open ? "rotate-180" : ""}`}
/>
</button>
</div>
{open && (
<div className="pb-1">
{items.map((item) =>
item.kind === "comment" ? (
<CommentCard
key={`c-${item.comment.id}`}
comment={item.comment}
issueMap={issueMap}
/>
) : (
<InteractionCard
key={`i-${item.interaction.id}`}
interaction={item.interaction}
caseNumber={caseNumber}
issueMap={issueMap}
/>
),
)}
</div>
)}
</div>
);
}
/* ── Compose: directive box + target selector (top of tab) ────── */
const isOpenStatus = (s: string) => s !== "done" && s !== "cancelled";
/** Client mirror of the server's pick_default_comment_target
* (web/paperclip_client.py): prefer the newest OPEN top-level issue (the live
* CEO main issue), then any open issue, then any top-level, else newest.
* `issues` arrives oldest→newest, so the last match is the newest. */
function pickDefaultTarget(issues: PaperclipIssue[]): PaperclipIssue | null {
if (!issues.length) return null;
const isTop = (i: PaperclipIssue) => i.parent_id == null;
const preds: ((i: PaperclipIssue) => boolean)[] = [
(i) => isOpenStatus(i.status) && isTop(i),
(i) => isOpenStatus(i.status),
isTop,
];
for (const p of preds) {
const m = issues.filter(p);
if (m.length) return m[m.length - 1];
}
return issues[issues.length - 1];
}
const STATUS_TONE: Record<string, string> = {
in_progress: "bg-emerald-100 text-emerald-700",
in_review: "bg-amber-100 text-amber-700",
todo: "bg-emerald-100 text-emerald-700",
backlog: "bg-emerald-100 text-emerald-700",
blocked: "bg-red-100 text-red-700",
done: "bg-gray-100 text-gray-500",
cancelled: "bg-red-50 text-red-600",
};
function statusTone(s: string) {
return STATUS_TONE[s] ?? "bg-gray-100 text-gray-600";
}
/** Where a chair instruction is routed. `auto` lets the server pick the live
* CEO main issue; `issue` targets an explicit one; `new_run` opens a fresh
* CEO-owned run (bypasses the human-owned-issue cancellation). */
type Target =
| { kind: "auto" }
| { kind: "issue"; id: string }
| { kind: "new_run" };
function TargetSelector({
issues,
target,
onChange,
}: {
issues: PaperclipIssue[];
target: Target;
onChange: (t: Target) => void;
}) {
const [open, setOpen] = useState(false);
const defaultIssue = useMemo(() => pickDefaultTarget(issues), [issues]);
const activeIssues = issues.filter((i) => isOpenStatus(i.status));
const closedIssues = issues.filter((i) => !isOpenStatus(i.status));
// id → identifier, to label a run with the parent it hangs under (↳ CMP-189).
const idToIdentifier = useMemo(
() => new Map(issues.map((i) => [i.id, i.identifier])),
[issues],
);
const parentTag = (i: PaperclipIssue) =>
i.parent_id ? idToIdentifier.get(i.parent_id) : undefined;
// The issue the pill currently represents (explicit pick, or the auto default).
const effectiveIssue =
target.kind === "issue"
? issues.find((i) => i.id === target.id) ?? null
: target.kind === "auto"
? defaultIssue
: null;
return (
<div className="relative min-w-0">
<button
type="button"
onClick={() => setOpen((o) => !o)}
aria-expanded={open}
className="flex items-center gap-2 bg-sand-soft/60 border border-rule rounded-lg px-2.5 py-1.5 hover:border-gold/60 min-w-0 max-w-full"
>
<Target className="w-3.5 h-3.5 text-gold-deep shrink-0" />
{target.kind === "new_run" ? (
<span className="text-xs font-semibold text-navy">פתח הרצה חדשה</span>
) : effectiveIssue ? (
<>
<Badge variant="outline" className="text-[10px] font-mono shrink-0">
{effectiveIssue.identifier}
</Badge>
<span className="text-xs font-medium text-navy truncate">
{shortIssueTitle(effectiveIssue.title)}
</span>
<span
className={`text-[10px] font-semibold rounded-full px-2 py-0.5 shrink-0 ${statusTone(effectiveIssue.status)}`}
>
{issueStatusLabel(effectiveIssue.status)}
</span>
</>
) : (
<span className="text-xs text-ink-muted">בחר יעד</span>
)}
<ChevronDown className="w-3.5 h-3.5 text-ink-faint shrink-0 ms-1" />
</button>
{open && (
<>
<div
className="fixed inset-0 z-10"
onClick={() => setOpen(false)}
aria-hidden
/>
<div className="absolute z-20 top-full mt-1 start-0 w-[360px] max-w-[88vw] max-h-[62vh] overflow-y-auto overscroll-contain bg-white border border-rule rounded-xl shadow-lg p-1.5">
{/* "פתח הרצה חדשה" is the FIRST, most-prominent option so it's always
visible without scrolling to the bottom of a long task list. */}
<button
type="button"
onClick={() => {
onChange({ kind: "new_run" });
setOpen(false);
}}
className="w-full flex items-center gap-2.5 px-2 py-2 rounded-lg text-start bg-gold/10 border border-rule hover:border-gold/60"
>
<span className="w-6 h-6 rounded-md bg-navy text-white flex items-center justify-center shrink-0">
<Plus className="w-4 h-4" />
</span>
<span className="min-w-0">
<span className="block text-xs font-semibold text-navy">
פתח הרצה חדשה
</span>
<span className="block text-[10px] text-ink-muted">
ה-CEO ייצור משימה חדשה ויטפל בהוראה מאפס
</span>
</span>
</button>
<div className="h-px bg-rule-soft my-1.5 mx-1" />
<div className="px-2 py-1 text-[10px] font-bold text-ink-faint uppercase tracking-wide">
משימות פעילות
</div>
{activeIssues.map((i) => {
const selected =
(target.kind === "issue" && target.id === i.id) ||
(target.kind === "auto" && defaultIssue?.id === i.id);
const parent = parentTag(i);
return (
<button
key={i.id}
type="button"
onClick={() => {
onChange({ kind: "issue", id: i.id });
setOpen(false);
}}
className={`w-full flex items-center gap-2 px-2 py-2 rounded-lg text-start hover:bg-sand-soft ${parent ? "ps-6" : ""} ${selected ? "bg-gold/10" : ""}`}
>
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 shrink-0" />
<Badge variant="outline" className="text-[10px] font-mono shrink-0">
{i.identifier}
</Badge>
{parent && (
<span
className="text-[9px] font-bold text-gold-deep bg-gold/10 border border-rule rounded-full px-1.5 py-0.5 shrink-0 whitespace-nowrap"
title={`תת-משימה של ${parent}`}
>
{parent}
</span>
)}
<span className="text-xs text-ink-soft truncate flex-1">
{shortIssueTitle(i.title)}
</span>
<span
className={`text-[10px] font-semibold rounded-full px-2 py-0.5 shrink-0 ${statusTone(i.status)}`}
>
{issueStatusLabel(i.status)}
</span>
{selected && (
<CheckCircle2 className="w-3.5 h-3.5 text-gold-deep shrink-0" />
)}
</button>
);
})}
{closedIssues.length > 0 && (
<>
<div className="h-px bg-rule-soft my-1.5 mx-1" />
<div className="px-2 py-1 text-[10px] font-bold text-ink-faint uppercase tracking-wide">
משימות סגורות · שליחה אליהן לא תעיר סוכן
</div>
{closedIssues.map((i) => {
const parent = parentTag(i);
return (
<div
key={i.id}
className={`w-full flex items-center gap-2 px-2 py-2 rounded-lg opacity-60 cursor-not-allowed ${parent ? "ps-6" : ""}`}
title="משימה סגורה — שליחה אליה לא תעיר סוכן"
>
<span className="w-1.5 h-1.5 rounded-full bg-ink-faint shrink-0" />
<Badge variant="outline" className="text-[10px] font-mono shrink-0">
{i.identifier}
</Badge>
{parent && (
<span
className="text-[9px] font-bold text-gold-deep bg-gold/10 border border-rule rounded-full px-1.5 py-0.5 shrink-0 whitespace-nowrap"
title={`תת-משימה של ${parent}`}
>
{parent}
</span>
)}
<span className="text-xs text-ink-faint truncate flex-1">
{shortIssueTitle(i.title)}
</span>
<span
className={`text-[10px] font-semibold rounded-full px-2 py-0.5 shrink-0 ${statusTone(i.status)}`}
>
{issueStatusLabel(i.status)}
</span>
</div>
);
})}
</>
)}
</div>
</>
)}
</div>
);
}
function Composer({
caseNumber,
issues,
}: {
caseNumber: string;
issues: PaperclipIssue[];
}) {
const sendComment = useSendComment(caseNumber);
const [body, setBody] = useState("");
const [target, setTarget] = useState<Target>({ kind: "auto" });
const selectedIssue =
target.kind === "issue" ? issues.find((i) => i.id === target.id) ?? null : null;
const closedTarget = !!selectedIssue && !isOpenStatus(selectedIssue.status);
const handleSend = () => {
if (!body.trim() || closedTarget) return;
const vars =
target.kind === "new_run"
? { body: body.trim(), new_run: true }
: target.kind === "issue"
? { body: body.trim(), issue_id: target.id }
: { body: body.trim() };
sendComment.mutate(vars, {
onSuccess: (res) => {
setBody("");
setTarget({ kind: "auto" });
toast.success(
res.new_run
? `נפתחה הרצה חדשה — ${res.issue_identifier}`
: `נשלח ל-${res.issue_identifier}`,
);
},
onError: () => toast.error("שגיאה בשליחת ההודעה"),
});
};
return (
<div className="bg-white border border-rule rounded-xl shadow-sm p-3.5">
<div className="flex items-center gap-2 mb-2.5">
<MessageSquare className="w-4 h-4 text-gold-deep" />
<h3 className="text-sm font-bold text-navy">הוראה לסוכנים</h3>
<span className="text-[11px] text-ink-faint ms-auto">
Ctrl+Enter לשליחה
</span>
</div>
<div className="flex items-center gap-2 mb-2 flex-wrap">
<span className="text-[11px] font-semibold text-ink-muted shrink-0">
שליחה אל:
</span>
<TargetSelector issues={issues} target={target} onChange={setTarget} />
</div>
{closedTarget && (
<div className="flex items-start gap-2 bg-red-50 border border-red-200 rounded-lg px-3 py-2 mb-2 text-[11.5px] text-red-700 leading-snug">
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" />
<div>
<b className="font-bold">יעד סגור.</b> שליחה למשימה סגורה לא תעיר סוכן
ההוראה לא תטופל. בחר משימה פעילה או{" "}
<button
type="button"
className="font-bold underline"
onClick={() => setTarget({ kind: "new_run" })}
>
פתח הרצה חדשה
</button>
.
</div>
</div>
)}
<Textarea
value={body}
onChange={(e) => setBody(e.target.value)}
placeholder="כתוב הוראה לסוכנים..."
aria-label="הוראה לסוכנים"
className="min-h-[60px] resize-none text-sm"
dir="rtl"
onKeyDown={(e) => {
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
handleSend();
}
}}
/>
<div className="flex items-center mt-2">
<span className="text-[11px] text-ink-faint">
ההודעה תנותב דרך סוכן ה-CEO · היעד ניתן לשינוי למעלה
</span>
<Button
size="sm"
className="ms-auto"
onClick={handleSend}
disabled={!body.trim() || closedTarget || sendComment.isPending}
>
{sendComment.isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Send className="w-4 h-4 me-1" />
)}
שלח
</Button>
</div>
</div>
);
}
/* ── Main Feed ───────────────────────────────────────────────── */
export function AgentActivityFeed({
caseNumber,
}: {
caseNumber: string;
}) {
const { data, isLoading, error } = useAgentActivity(caseNumber);
// Build issue_id → identifier map (memoized — the feed refetches every 10s,
// and a fresh Map each render would defeat the child cards' memoization).
const issueMap = useMemo(() => {
const m = new Map<string, string>();
for (const iss of data?.issues ?? []) m.set(iss.id, iss.identifier);
return m;
}, [data?.issues]);
// ── Empty / loading states ──
if (isLoading) {
return (
<div className="flex items-center justify-center py-12 text-ink-faint">
<Loader2 className="w-5 h-5 animate-spin me-2" />
<span>טוען פעילות סוכנים...</span>
</div>
);
}
if (error) {
return (
<div className="text-center py-12 text-red-500 text-sm">
שגיאה בטעינת פעילות סוכנים
</div>
);
}
if (!data?.issues?.length) {
return (
<div className="text-center py-12 space-y-2">
<MessageSquare className="w-10 h-10 mx-auto text-ink-faint/40" />
<p className="text-sm text-ink-faint">
התהליך טרם הופעל. לחץ &quot;התחל תהליך&quot; בלשונית סקירה.
</p>
</div>
);
}
const comments = data.comments ?? [];
const interactions = data.interactions ?? [];
// 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");
// Group the timeline by issue, each an accordion (mockup 18i v2). Comments +
// resolved interactions; pending ones are surfaced above and not repeated.
const itemsByIssue = new Map<string, FeedItem[]>();
const pushItem = (issueId: string, item: FeedItem) => {
const arr = itemsByIssue.get(issueId);
if (arr) arr.push(item);
else itemsByIssue.set(issueId, [item]);
};
for (const c of comments) {
pushItem(c.issue_id, {
kind: "comment",
at: c.created_at ? new Date(c.created_at).getTime() : 0,
comment: c,
});
}
for (const i of interactions) {
if (i.status === "pending") continue;
pushItem(i.issue_id, {
kind: "interaction",
at: i.created_at ? new Date(i.created_at).getTime() : 0,
interaction: i,
});
}
for (const arr of itemsByIssue.values()) arr.sort((a, b) => a.at - b.at);
// Order groups by most-recent activity so live work floats to the top.
const issueGroups = data.issues
.map((iss) => {
const items = itemsByIssue.get(iss.id) ?? [];
const maxAt = items.reduce((m, it) => Math.max(m, it.at), 0);
return { iss, items, maxAt };
})
.filter((g) => g.items.length > 0)
.sort((a, b) => b.maxAt - a.maxAt);
const totalFeedItems = issueGroups.reduce((n, g) => n + g.items.length, 0);
// An issue is "active" if it's not done/cancelled. When everything is closed
// we should NOT show the "agents are working, waiting for report" spinner.
const hasActiveIssue = data.issues.some(
(iss) => iss.status !== "done" && iss.status !== "cancelled",
);
return (
<div className="flex flex-col h-full gap-3">
{/* directive box — moved to the TOP, above the activity (most available,
no scrolling). Carries the target selector. */}
<Composer caseNumber={caseNumber} issues={data.issues} />
{/* agent roster — who's who, compact one-row (mockup 18i v2) */}
<AgentRoster agents={data.agents ?? []} />
{/* pending interactions surfaced to the top — each an accordion, all
COLLAPSED by default (chair opens to look). Excluded from the timeline. */}
{pending.length > 0 && (
<div className="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}
defaultOpen={false}
/>
))}
</div>
)}
{/* Timeline header */}
<div className="flex items-center gap-2 px-2 py-2 border-b border-rule">
<span className="text-[0.82rem] font-bold text-navy">פעילות לפי משימה</span>
<span className="text-[0.68rem] font-semibold text-gold-deep bg-gold/10 rounded-full px-2 py-0.5">
החדש למעלה
</span>
<span className="text-[0.72rem] text-ink-muted ms-auto">
{data.issues.length} משימות · {totalFeedItems} הודעות
</span>
</div>
{/* Per-issue accordions — all COLLAPSED by default, newest group first */}
<div className="flex-1 overflow-y-auto max-h-[500px]">
{totalFeedItems === 0 ? (
hasActiveIssue ? (
<div className="text-center py-8 text-ink-faint text-sm">
<Loader2 className="w-5 h-5 animate-spin mx-auto mb-2" />
הסוכנים התחילו לעבוד, ממתין לדיווח ראשון...
</div>
) : (
<div className="text-center py-8 text-ink-faint text-sm">
<MessageSquare className="w-5 h-5 mx-auto mb-2 opacity-50" />
אין משימות פעילות בתיק. כל המשימות הסתיימו או בוטלו.
</div>
)
) : (
issueGroups.map(({ iss, items }) => (
<IssueGroup
key={iss.id}
issue={iss}
items={items}
caseNumber={caseNumber}
issueMap={issueMap}
defaultOpen={false}
/>
))
)}
</div>
</div>
);
}