- Agents tab: render the AgentStatusWidget roster at the top of the activity
feed ("who's doing what right now") and surface pending agent interactions in
an amber "ממתין לתשובתך" banner above the timeline (mockup 18i key wins).
- Drafts tab: Hebraize the drafts-table headers File/Size/Date → קובץ/גודל/תאריך
(mockup 18h).
All existing feed logic, mutations and interaction forms preserved.
tsc --noEmit + eslint clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
819 lines
27 KiB
TypeScript
819 lines
27 KiB
TypeScript
"use client";
|
||
|
||
import { useRef, useState, useEffect, 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 { AgentStatusWidget } from "@/components/cases/agent-status-widget";
|
||
import {
|
||
useAgentActivity,
|
||
useSendComment,
|
||
useSubmitInteraction,
|
||
} from "@/lib/api/agents";
|
||
import type {
|
||
Interaction,
|
||
InteractionPayload,
|
||
InteractionQuestion,
|
||
InteractionTask,
|
||
PaperclipComment,
|
||
} from "@/lib/api/agents";
|
||
import { formatRelative } from "@/lib/format-date";
|
||
import { toast } from "sonner";
|
||
import {
|
||
Bot,
|
||
User,
|
||
Send,
|
||
Loader2,
|
||
MessageSquare,
|
||
Clock,
|
||
CheckCircle2,
|
||
XCircle,
|
||
HelpCircle,
|
||
} 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;
|
||
}
|
||
|
||
/* ── 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,
|
||
}: {
|
||
interaction: Interaction;
|
||
caseNumber: string;
|
||
issueMap: Map<string, string>;
|
||
}) {
|
||
const submit = useSubmitInteraction(caseNumber);
|
||
const identifier = issueMap.get(interaction.issue_id) ?? "";
|
||
const isPending = interaction.status === "pending";
|
||
const summary = summaryAnswer(interaction);
|
||
|
||
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("שגיאה בשליחת התשובה"),
|
||
},
|
||
);
|
||
};
|
||
|
||
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">
|
||
<span className="text-sm font-semibold text-navy">
|
||
{interaction.title || "שאלה לסוכן"}
|
||
</span>
|
||
{isPending ? (
|
||
<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>
|
||
) : (
|
||
<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>
|
||
</div>
|
||
|
||
{interaction.summary && (
|
||
<div className="text-xs text-ink-faint mb-2">{interaction.summary}</div>
|
||
)}
|
||
|
||
{isPending ? (
|
||
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
|
||
) : summary ? (
|
||
<div className="text-sm">
|
||
<Markdown content={summary} />
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ── Main Feed ───────────────────────────────────────────────── */
|
||
|
||
export function AgentActivityFeed({
|
||
caseNumber,
|
||
}: {
|
||
caseNumber: string;
|
||
}) {
|
||
const { data, isLoading, error } = useAgentActivity(caseNumber);
|
||
const sendComment = useSendComment(caseNumber);
|
||
const [body, setBody] = useState("");
|
||
const endRef = useRef<HTMLDivElement>(null);
|
||
|
||
// 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]);
|
||
|
||
// Auto-scroll on new comments or interactions
|
||
const commentCount = data?.comments?.length ?? 0;
|
||
const interactionCount = data?.interactions?.length ?? 0;
|
||
useEffect(() => {
|
||
endRef.current?.scrollIntoView({ behavior: "smooth" });
|
||
}, [commentCount, interactionCount]);
|
||
|
||
const handleSend = () => {
|
||
if (!body.trim()) return;
|
||
sendComment.mutate(
|
||
{ body: body.trim() },
|
||
{
|
||
onSuccess: (res) => {
|
||
setBody("");
|
||
toast.success(`נשלח ל-${res.issue_identifier}`);
|
||
},
|
||
onError: () => toast.error("שגיאה בשליחת ההודעה"),
|
||
},
|
||
);
|
||
};
|
||
|
||
// ── 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">
|
||
התהליך טרם הופעל. לחץ "התחל תהליך" בלשונית סקירה.
|
||
</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const comments = data.comments ?? [];
|
||
const interactions = data.interactions ?? [];
|
||
const pendingCount = interactions.filter((i) => i.status === "pending").length;
|
||
|
||
// Unified, time-sorted feed: comments + interactions interleaved.
|
||
type FeedItem =
|
||
| { kind: "comment"; at: number; comment: PaperclipComment }
|
||
| { kind: "interaction"; at: number; interaction: Interaction };
|
||
|
||
const feed: FeedItem[] = [
|
||
...comments.map<FeedItem>((c) => ({
|
||
kind: "comment",
|
||
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,
|
||
})),
|
||
].sort((a, b) => a.at - b.at);
|
||
|
||
// 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">
|
||
{/* 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>
|
||
|
||
{/* 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 ? "בקשה" : "בקשות"} בהמשך הזרם
|
||
</div>
|
||
)}
|
||
|
||
{/* Issue summary bar */}
|
||
<div className="flex items-center gap-2 px-2 py-2 border-b border-rule mb-2 flex-wrap">
|
||
{data.issues.map((iss) => (
|
||
<Badge
|
||
key={iss.id}
|
||
variant={iss.status === "done" ? "secondary" : "default"}
|
||
className="text-[11px] font-mono"
|
||
>
|
||
{iss.identifier} — {issueStatusLabel(iss.status)}
|
||
</Badge>
|
||
))}
|
||
</div>
|
||
|
||
{/* Comments + interactions stream */}
|
||
<div className="flex-1 overflow-y-auto max-h-[500px] space-y-1 px-1">
|
||
{feed.length === 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>
|
||
)
|
||
) : (
|
||
feed.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 ref={endRef} />
|
||
</div>
|
||
|
||
{/* Comment input */}
|
||
<div className="border-t border-rule pt-3 mt-3 space-y-2">
|
||
<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 justify-between">
|
||
<span className="text-[11px] text-ink-faint">
|
||
ההודעה תנותב דרך סוכן ה-CEO · Ctrl+Enter לשליחה
|
||
</span>
|
||
<Button
|
||
size="sm"
|
||
onClick={handleSend}
|
||
disabled={!body.trim() || sendComment.isPending}
|
||
>
|
||
{sendComment.isPending ? (
|
||
<Loader2 className="w-4 h-4 animate-spin" />
|
||
) : (
|
||
<Send className="w-4 h-4 me-1" />
|
||
)}
|
||
שלח
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|