All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 31s
Role labels: ceo→מנהל, researcher→חוקר, engineer→מהנדס, qa→בודק איכות Issue status: in_progress→בביצוע, done→הושלם, todo→לביצוע, etc. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
294 lines
9.1 KiB
TypeScript
294 lines
9.1 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 } from "@/lib/api/agents";
|
||
import type { PaperclipComment } from "@/lib/api/agents";
|
||
import { toast } from "sonner";
|
||
import {
|
||
Bot,
|
||
User,
|
||
Send,
|
||
Loader2,
|
||
MessageSquare,
|
||
Clock,
|
||
} 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>
|
||
);
|
||
}
|
||
|
||
/* ── 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
|
||
const commentCount = data?.comments?.length ?? 0;
|
||
useEffect(() => {
|
||
endRef.current?.scrollIntoView({ behavior: "smooth" });
|
||
}, [commentCount]);
|
||
|
||
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 ?? [];
|
||
|
||
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 stream */}
|
||
<div className="flex-1 overflow-y-auto max-h-[500px] space-y-1 px-1">
|
||
{comments.length === 0 ? (
|
||
<div className="text-center py-8 text-ink-faint text-sm">
|
||
<Loader2 className="w-5 h-5 animate-spin mx-auto mb-2" />
|
||
הסוכנים התחילו לעבוד, ממתין לדיווח ראשון...
|
||
</div>
|
||
) : (
|
||
comments.map((c) => (
|
||
<CommentCard key={c.id} comment={c} 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>
|
||
);
|
||
}
|