diff --git a/web-ui/src/components/cases/agent-activity-feed.tsx b/web-ui/src/components/cases/agent-activity-feed.tsx index 4609d99..ab11c9a 100644 --- a/web-ui/src/components/cases/agent-activity-feed.tsx +++ b/web-ui/src/components/cases/agent-activity-feed.tsx @@ -1,6 +1,6 @@ "use client"; -import { useRef, useState, useEffect, useMemo } from "react"; +import { useState, useMemo } from "react"; import { Button } from "@/components/ui/button"; import { Textarea } from "@/components/ui/textarea"; import { Badge } from "@/components/ui/badge"; @@ -34,6 +34,9 @@ import { HelpCircle, ChevronDown, Ban, + Target, + Plus, + AlertTriangle, } from "lucide-react"; /* ── Role → color mapping ────────────────────────────────────── */ @@ -818,6 +821,312 @@ function IssueGroup({ ); } +/* ── 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 = { + 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 ( +
+ + + {open && ( + <> +
setOpen(false)} + aria-hidden + /> +
+
+ משימות פעילות +
+ {activeIssues.map((i) => { + const selected = + (target.kind === "issue" && target.id === i.id) || + (target.kind === "auto" && defaultIssue?.id === i.id); + return ( + + ); + })} + + {closedIssues.length > 0 && ( + <> +
+
+ משימות סגורות · שליחה אליהן לא תעיר סוכן +
+ {closedIssues.map((i) => ( +
+ + + {i.identifier} + + + {shortIssueTitle(i.title)} + + + {issueStatusLabel(i.status)} + +
+ ))} + + )} + +
+ +
+ + )} +
+ ); +} + +function Composer({ + caseNumber, + issues, +}: { + caseNumber: string; + issues: PaperclipIssue[]; +}) { + const sendComment = useSendComment(caseNumber); + const [body, setBody] = useState(""); + const [target, setTarget] = useState({ 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 ( +
+
+ +

הוראה לסוכנים

+ + Ctrl+Enter לשליחה + +
+ +
+ + שליחה אל: + + +
+ + {closedTarget && ( +
+ +
+ יעד סגור. שליחה למשימה סגורה לא תעיר סוכן — + ההוראה לא תטופל. בחר משימה פעילה או{" "} + + . +
+
+ )} + +