All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 47s
Surface issue_thread_interactions (ask_user_questions / request_confirmation /
suggest_tasks) directly inside legal-ai's case detail feed so the user can
answer agent prompts without switching to Paperclip's UI.
Backend (FastAPI):
- paperclip_client.py: 4 new helpers — get_issue_interactions (DB),
respond_to_interaction / accept_interaction / reject_interaction (REST).
- app.py: extends GET /api/cases/{case_number}/agents to include
`interactions`, and adds POST /api/cases/{case_number}/agents/interaction-response
routing to /respond, /accept, /reject in Paperclip.
- paperclip_client.py: also pulls existing httpx calls onto the centralized
pc_request helper (paperclip_api.py) for consistent auth + run-id headers.
Frontend (web-ui, Next.js 16 + TanStack Query):
- agents.ts: Interaction / InteractionPayload / InteractionStatus types,
useSubmitInteraction mutation hook (invalidates the activity query).
- agent-activity-feed.tsx: InteractionCard renders radio (single) /
checkbox (multi) for ask_user_questions, accept/reject + reason for
request_confirmation, task selection for suggest_tasks. Resolved
interactions show a read-only summary. Cards are interleaved with
comments by created_at, so the feed reads chronologically.
Paperclip auto-wakes the issue assignee on a successful response
(queueResolvedInteractionContinuationWakeup) — no explicit wakeup needed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
810 lines
26 KiB
TypeScript
810 lines
26 KiB
TypeScript
"use client";
|
||
|
||
import { useRef, useState, useEffect } 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,
|
||
} from "@/lib/api/agents";
|
||
import type {
|
||
Interaction,
|
||
InteractionPayload,
|
||
InteractionQuestion,
|
||
InteractionTask,
|
||
PaperclipComment,
|
||
} from "@/lib/api/agents";
|
||
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 {
|
||
if (!iso) return "";
|
||
const diff = Date.now() - new Date(iso).getTime();
|
||
const mins = Math.floor(diff / 60_000);
|
||
if (mins < 1) return "עכשיו";
|
||
if (mins < 60) return `לפני ${mins} דק׳`;
|
||
const hours = Math.floor(mins / 60);
|
||
if (hours < 24) return `לפני ${hours} שע׳`;
|
||
const days = Math.floor(hours / 24);
|
||
return `לפני ${days} ימים`;
|
||
}
|
||
|
||
/* ── 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 mr-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 mr-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 ml-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 ml-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 ml-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 ml-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 ml-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 mr-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
|
||
const issueMap = new Map<string, string>();
|
||
if (data?.issues) {
|
||
for (const iss of data.issues) {
|
||
issueMap.set(iss.id, iss.identifier);
|
||
}
|
||
}
|
||
|
||
// 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 ml-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 ?? [];
|
||
|
||
// 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">
|
||
{/* 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="כתוב הוראה לסוכנים..."
|
||
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 ml-1" />
|
||
)}
|
||
שלח
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|