טאב הסוכנים בדף-התיק — מימוש מוקאפ X17 מאושר (18i3), פותר את התלונה החוזרת
שהיו"ר נחת בתחתית העמוד, שדה-ההוראה למטה, והטיוטה החדשה נבלעה בגלילה.
Frontend (web-ui/agent-activity-feed.tsx):
- **שדה-ההוראה עלה לראש הטאב** (Composer), מעל הפעילות — הכי זמין, בלי גלילה.
בוטל ה-auto-scroll-to-bottom (endRef/useEffect הוסרו).
- **כל הפעילות מקופלת כברירת-מחדל** — קבוצות-המשימה וגם "ממתין לתשובתך"
(defaultOpen=false); היו"ר פותח כדי להסתכל. סדר החדש-למעלה (תג "החדש למעלה").
- **בורר-יעד** (TargetSelector): מציג לאיזו משימה ההוראה תלך, בוחר משימה פעילה,
מסמן משימות סגורות כמנוטרלות ("שליחה אליהן לא תעיר סוכן"), אזהרת יעד-סגור
עם כפתור-שליחה מנוטרל, ואופציית **"פתח הרצה חדשה"**. מחשב את יעד-ברירת-המחדל
בצד-לקוח במראָה ל-pick_default_comment_target.
Backend:
- `open_ceo_run` (paperclip_client) — פותח הרצת-CEO טרייה דרך פרימיטיב ה-CEO-child
(#227): יוצר issue-ילד בבעלות CEO ומעיר עליו, ועוקף את issue_assignee_changed
על issue בבעלות-אדם (הבאג של #228). נחשף דרך ה-Port כ-`pc_open_ceo_run`.
- `/agents/comment` מקבל `new_run:bool`; `PaperclipIssue` מחזיר `parent_id`
(לבורר בצד-לקוח).
Invariants: G1 (נרמול-במקור), G2 (אין מסלול-ניתוב מקביל), G12 (מגע-Paperclip דרך ה-Port).
מקדם TaskMaster #228 (מנתב הרצה-חדשה דרך פרימיטיב ה-CEO-child).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1299 lines
44 KiB
TypeScript
1299 lines
44 KiB
TypeScript
"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,
|
||
} 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 closed = issue.status === "done" || issue.status === "cancelled";
|
||
return (
|
||
<div className="border-b border-rule-soft last:border-b-0">
|
||
<button
|
||
type="button"
|
||
onClick={() => setOpen((o) => !o)}
|
||
aria-expanded={open}
|
||
className="w-full flex items-center gap-2 px-2 py-2.5 hover:bg-sand-soft/50 text-start"
|
||
>
|
||
<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>
|
||
<span className="text-[0.7rem] text-ink-muted whitespace-nowrap">
|
||
{issueStatusLabel(issue.status)} · {items.length}
|
||
</span>
|
||
<ChevronDown
|
||
className={`w-4 h-4 ms-auto text-ink-faint shrink-0 transition-transform ${open ? "rotate-180" : ""}`}
|
||
/>
|
||
</button>
|
||
{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));
|
||
|
||
// 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] bg-white border border-rule rounded-xl shadow-lg p-1.5">
|
||
<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);
|
||
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 ${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>
|
||
<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) => (
|
||
<div
|
||
key={i.id}
|
||
className="w-full flex items-center gap-2 px-2 py-2 rounded-lg opacity-60 cursor-not-allowed"
|
||
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>
|
||
<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 className="h-px bg-rule-soft my-1.5 mx-1" />
|
||
<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 hover:bg-sand-soft"
|
||
>
|
||
<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>
|
||
</>
|
||
)}
|
||
</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">
|
||
התהליך טרם הופעל. לחץ "התחל תהליך" בלשונית סקירה.
|
||
</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>
|
||
);
|
||
}
|