Files
legal-ai/web-ui/src/components/cases/agent-activity-feed.tsx
Chaim d0994704cf
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 47s
feat(agents): mirror Paperclip interactions in case page
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>
2026-05-04 16:40:45 +00:00

810 lines
26 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 { 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">
התהליך טרם הופעל. לחץ &quot;התחל תהליך&quot; בלשונית סקירה.
</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 &middot; 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>
);
}