Compare commits
2 Commits
0a5be942ba
...
107be235f5
| Author | SHA1 | Date | |
|---|---|---|---|
| 107be235f5 | |||
| 9e7d98a47a |
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useRef, useState, useEffect, useMemo } from "react";
|
import { useState, useMemo } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
@@ -34,6 +34,9 @@ import {
|
|||||||
HelpCircle,
|
HelpCircle,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
Ban,
|
Ban,
|
||||||
|
Target,
|
||||||
|
Plus,
|
||||||
|
AlertTriangle,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
/* ── Role → color mapping ────────────────────────────────────── */
|
/* ── 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<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 ───────────────────────────────────────────────── */
|
/* ── Main Feed ───────────────────────────────────────────────── */
|
||||||
|
|
||||||
export function AgentActivityFeed({
|
export function AgentActivityFeed({
|
||||||
@@ -826,9 +1135,6 @@ export function AgentActivityFeed({
|
|||||||
caseNumber: string;
|
caseNumber: string;
|
||||||
}) {
|
}) {
|
||||||
const { data, isLoading, error } = useAgentActivity(caseNumber);
|
const { data, isLoading, error } = useAgentActivity(caseNumber);
|
||||||
const sendComment = useSendComment(caseNumber);
|
|
||||||
const [body, setBody] = useState("");
|
|
||||||
const endRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
// Build issue_id → identifier map (memoized — the feed refetches every 10s,
|
// Build issue_id → identifier map (memoized — the feed refetches every 10s,
|
||||||
// and a fresh Map each render would defeat the child cards' memoization).
|
// and a fresh Map each render would defeat the child cards' memoization).
|
||||||
@@ -838,27 +1144,6 @@ export function AgentActivityFeed({
|
|||||||
return m;
|
return m;
|
||||||
}, [data?.issues]);
|
}, [data?.issues]);
|
||||||
|
|
||||||
// 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 ──
|
// ── Empty / loading states ──
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
@@ -939,14 +1224,18 @@ export function AgentActivityFeed({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<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) */}
|
{/* agent roster — who's who, compact one-row (mockup 18i v2) */}
|
||||||
<AgentRoster agents={data.agents ?? []} />
|
<AgentRoster agents={data.agents ?? []} />
|
||||||
|
|
||||||
{/* pending interactions surfaced to the top — each an accordion, first
|
{/* pending interactions surfaced to the top — each an accordion, all
|
||||||
open (mockup 18i v2 — awaiting you). Excluded from the timeline. */}
|
COLLAPSED by default (chair opens to look). Excluded from the timeline. */}
|
||||||
{pending.length > 0 && (
|
{pending.length > 0 && (
|
||||||
<div className="mb-3 rounded-lg border border-amber-300 bg-amber-50 p-3 space-y-2">
|
<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">
|
<div className="flex items-center gap-2 text-[0.82rem] font-bold text-amber-800">
|
||||||
<Clock className="w-4 h-4" />
|
<Clock className="w-4 h-4" />
|
||||||
ממתין לתשובתך
|
ממתין לתשובתך
|
||||||
@@ -954,27 +1243,30 @@ export function AgentActivityFeed({
|
|||||||
{pending.length}
|
{pending.length}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{pending.map((i, idx) => (
|
{pending.map((i) => (
|
||||||
<InteractionCard
|
<InteractionCard
|
||||||
key={`pending-${i.id}`}
|
key={`pending-${i.id}`}
|
||||||
interaction={i}
|
interaction={i}
|
||||||
caseNumber={caseNumber}
|
caseNumber={caseNumber}
|
||||||
issueMap={issueMap}
|
issueMap={issueMap}
|
||||||
defaultOpen={idx === 0}
|
defaultOpen={false}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Timeline header */}
|
{/* Timeline header */}
|
||||||
<div className="flex items-center gap-2 px-2 py-2 border-b border-rule mb-1">
|
<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.82rem] font-bold text-navy">פעילות לפי משימה</span>
|
||||||
<span className="text-[0.72rem] text-ink-muted">
|
<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} הודעות
|
{data.issues.length} משימות · {totalFeedItems} הודעות
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Per-issue accordions */}
|
{/* Per-issue accordions — all COLLAPSED by default, newest group first */}
|
||||||
<div className="flex-1 overflow-y-auto max-h-[500px]">
|
<div className="flex-1 overflow-y-auto max-h-[500px]">
|
||||||
{totalFeedItems === 0 ? (
|
{totalFeedItems === 0 ? (
|
||||||
hasActiveIssue ? (
|
hasActiveIssue ? (
|
||||||
@@ -989,56 +1281,17 @@ export function AgentActivityFeed({
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
issueGroups.map(({ iss, items }, idx) => (
|
issueGroups.map(({ iss, items }) => (
|
||||||
<IssueGroup
|
<IssueGroup
|
||||||
key={iss.id}
|
key={iss.id}
|
||||||
issue={iss}
|
issue={iss}
|
||||||
items={items}
|
items={items}
|
||||||
caseNumber={caseNumber}
|
caseNumber={caseNumber}
|
||||||
issueMap={issueMap}
|
issueMap={issueMap}
|
||||||
defaultOpen={
|
defaultOpen={false}
|
||||||
idx === 0 ||
|
|
||||||
(iss.status !== "done" && iss.status !== "cancelled")
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
<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="כתוב הוראה לסוכנים..."
|
|
||||||
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 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 me-1" />
|
|
||||||
)}
|
|
||||||
שלח
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ export type PaperclipIssue = {
|
|||||||
completed_at: string | null;
|
completed_at: string | null;
|
||||||
created_at: string | null;
|
created_at: string | null;
|
||||||
company_id: string;
|
company_id: string;
|
||||||
|
/** Null for top-level issues (the CEO "start process" issue); set for sub-issues. */
|
||||||
|
parent_id: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PaperclipComment = {
|
export type PaperclipComment = {
|
||||||
@@ -151,8 +153,14 @@ export function useAgentActivity(caseNumber: string | undefined) {
|
|||||||
export function useSendComment(caseNumber: string | undefined) {
|
export function useSendComment(caseNumber: string | undefined) {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (vars: { body: string; issue_id?: string }) =>
|
mutationFn: (vars: { body: string; issue_id?: string; new_run?: boolean }) =>
|
||||||
apiRequest<{ comment_id: string; issue_id: string; issue_identifier: string }>(
|
apiRequest<{
|
||||||
|
comment_id: string | null;
|
||||||
|
issue_id: string;
|
||||||
|
issue_identifier: string;
|
||||||
|
issue_status?: string;
|
||||||
|
new_run?: boolean;
|
||||||
|
}>(
|
||||||
`/api/cases/${caseNumber}/agents/comment`,
|
`/api/cases/${caseNumber}/agents/comment`,
|
||||||
{ method: "POST", body: vars },
|
{ method: "POST", body: vars },
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ from web.paperclip_client import (
|
|||||||
wake_analyst_for_protocol_analysis as _wake_analyst_for_protocol_analysis,
|
wake_analyst_for_protocol_analysis as _wake_analyst_for_protocol_analysis,
|
||||||
wake_ceo_agent as _wake_ceo,
|
wake_ceo_agent as _wake_ceo,
|
||||||
wake_ceo_for_action as _wake_ceo_for_action,
|
wake_ceo_for_action as _wake_ceo_for_action,
|
||||||
|
open_ceo_run as _open_ceo_run,
|
||||||
wake_ceo_for_feedback_fold as _wake_ceo_for_feedback_fold,
|
wake_ceo_for_feedback_fold as _wake_ceo_for_feedback_fold,
|
||||||
wake_curator_for_final as _wake_curator_for_final,
|
wake_curator_for_final as _wake_curator_for_final,
|
||||||
wake_for_precedent_extraction as _wake_for_precedent_extraction,
|
wake_for_precedent_extraction as _wake_for_precedent_extraction,
|
||||||
@@ -89,6 +90,9 @@ pc_wake_ceo = instrument("agent.wakeup", role="ceo")(_wake_ceo)
|
|||||||
pc_wake_ceo_for_action = instrument(
|
pc_wake_ceo_for_action = instrument(
|
||||||
"agent.wakeup", role="ceo", keys=("case_number", "company_id", "action"),
|
"agent.wakeup", role="ceo", keys=("case_number", "company_id", "action"),
|
||||||
)(_wake_ceo_for_action)
|
)(_wake_ceo_for_action)
|
||||||
|
pc_open_ceo_run = instrument(
|
||||||
|
"agent.wakeup", role="ceo", keys=("case_number", "company_id"),
|
||||||
|
)(_open_ceo_run)
|
||||||
pc_wake_ceo_for_feedback_fold = instrument(
|
pc_wake_ceo_for_feedback_fold = instrument(
|
||||||
"agent.wakeup", role="ceo", keys=("feedback_id", "category", "block_id"),
|
"agent.wakeup", role="ceo", keys=("feedback_id", "category", "block_id"),
|
||||||
)(_wake_ceo_for_feedback_fold)
|
)(_wake_ceo_for_feedback_fold)
|
||||||
@@ -165,6 +169,7 @@ __all__ = [
|
|||||||
"pc_get_agents",
|
"pc_get_agents",
|
||||||
"pc_wake_ceo",
|
"pc_wake_ceo",
|
||||||
"pc_wake_ceo_for_action",
|
"pc_wake_ceo_for_action",
|
||||||
|
"pc_open_ceo_run",
|
||||||
"pc_wake_ceo_for_feedback_fold",
|
"pc_wake_ceo_for_feedback_fold",
|
||||||
"pc_wake_curator_for_final",
|
"pc_wake_curator_for_final",
|
||||||
"pc_wake_for_precedent_extraction",
|
"pc_wake_for_precedent_extraction",
|
||||||
|
|||||||
30
web/app.py
30
web/app.py
@@ -90,6 +90,7 @@ from web.agent_platform_port import (
|
|||||||
rename_case_project,
|
rename_case_project,
|
||||||
pc_wake_ceo,
|
pc_wake_ceo,
|
||||||
pc_wake_ceo_for_action,
|
pc_wake_ceo_for_action,
|
||||||
|
pc_open_ceo_run,
|
||||||
pc_wake_ceo_for_feedback_fold,
|
pc_wake_ceo_for_feedback_fold,
|
||||||
pc_wake_curator_for_final,
|
pc_wake_curator_for_final,
|
||||||
pc_wake_for_precedent_extraction,
|
pc_wake_for_precedent_extraction,
|
||||||
@@ -4604,15 +4605,38 @@ async def api_case_agents(case_number: str):
|
|||||||
class AgentCommentRequest(BaseModel):
|
class AgentCommentRequest(BaseModel):
|
||||||
body: str
|
body: str
|
||||||
issue_id: str | None = None
|
issue_id: str | None = None
|
||||||
|
# "פתח הרצה חדשה" — open a fresh CEO-owned run for this instruction instead of
|
||||||
|
# appending to an existing issue (agents-tab target selector). Overrides issue_id.
|
||||||
|
new_run: bool = False
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/cases/{case_number}/agents/comment")
|
@app.post("/api/cases/{case_number}/agents/comment")
|
||||||
async def api_post_agent_comment(case_number: str, req: AgentCommentRequest):
|
async def api_post_agent_comment(case_number: str, req: AgentCommentRequest):
|
||||||
"""Post a comment on a Paperclip issue linked to a case.
|
"""Post a chair instruction to a Paperclip issue linked to a case.
|
||||||
|
|
||||||
If ``issue_id`` is omitted, routing prefers the live CEO main issue and
|
Routing (agents-tab target selector):
|
||||||
never a closed (done/cancelled) issue — see ``_pick_default_comment_target``.
|
- ``new_run=True`` → open a fresh CEO-owned run (child issue assigned to the
|
||||||
|
CEO + wakeup), sidestepping the ``issue_assignee_changed`` cancellation on
|
||||||
|
a human-owned parent. Overrides ``issue_id``.
|
||||||
|
- ``issue_id`` given → post to that issue (explicit chair choice).
|
||||||
|
- neither → prefer the live CEO main issue, never a closed one
|
||||||
|
(``pick_default_comment_target``).
|
||||||
"""
|
"""
|
||||||
|
if req.new_run:
|
||||||
|
result = await pc_open_ceo_run(case_number, req.body)
|
||||||
|
if result.get("status") != "ok":
|
||||||
|
raise HTTPException(
|
||||||
|
502,
|
||||||
|
f"פתיחת הרצה חדשה נכשלה: {result.get('reason', 'unknown')}",
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"comment_id": None,
|
||||||
|
"issue_id": result["issue_id"],
|
||||||
|
"issue_identifier": result.get("identifier", ""),
|
||||||
|
"issue_status": "in_progress",
|
||||||
|
"new_run": True,
|
||||||
|
}
|
||||||
|
|
||||||
issues = await pc_get_case_issues(case_number)
|
issues = await pc_get_case_issues(case_number)
|
||||||
if not issues:
|
if not issues:
|
||||||
raise HTTPException(404, f"לא נמצא פרויקט Paperclip לתיק {case_number}")
|
raise HTTPException(404, f"לא נמצא פרויקט Paperclip לתיק {case_number}")
|
||||||
|
|||||||
@@ -1739,6 +1739,93 @@ async def wake_ceo_for_action(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def open_ceo_run(
|
||||||
|
case_number: str,
|
||||||
|
instruction: str,
|
||||||
|
company_id: str = "",
|
||||||
|
) -> dict:
|
||||||
|
"""Open a FRESH CEO-owned run carrying a free-form chair instruction.
|
||||||
|
|
||||||
|
Drives the "פתח הרצה חדשה" option of the agents-tab target selector: instead
|
||||||
|
of appending the instruction to an existing (possibly human-owned or closed)
|
||||||
|
issue, a **child issue assigned to the CEO** is created under the case's main
|
||||||
|
issue and the CEO is woken on it. This is the same CEO-child primitive as
|
||||||
|
:func:`wake_ceo_for_action` (#227) — it sidesteps the ``issue_assignee_changed``
|
||||||
|
cancellation that fires when an agent is woken on a human-owned parent (the
|
||||||
|
exact silent failure the chair hit on 1027-04-26; see TaskMaster #228).
|
||||||
|
|
||||||
|
Wakeup goes through the Paperclip API, never a direct DB insert. Returns
|
||||||
|
``{"status":"ok", "issue_id"(=child), "identifier", ...}`` or
|
||||||
|
``{"status":"skipped", "reason": ...}``.
|
||||||
|
"""
|
||||||
|
if not PAPERCLIP_BOARD_API_KEY:
|
||||||
|
logger.warning("PAPERCLIP_BOARD_API_KEY not set — skipping CEO new-run")
|
||||||
|
return {"status": "skipped", "reason": "no_api_key"}
|
||||||
|
|
||||||
|
issues = await get_case_issues(case_number)
|
||||||
|
if not issues:
|
||||||
|
logger.warning("No Paperclip issues for case %s — skipping CEO new-run", case_number)
|
||||||
|
return {"status": "skipped", "reason": "no_issue"}
|
||||||
|
|
||||||
|
# Parent the new run under the live main issue (never a closed child).
|
||||||
|
main_issue = pick_default_comment_target(issues)
|
||||||
|
main_issue_id = main_issue["id"]
|
||||||
|
if not company_id:
|
||||||
|
company_id = main_issue.get("company_id", "")
|
||||||
|
ceo_id = CEO_AGENTS.get(company_id, CEO_AGENT_ID)
|
||||||
|
|
||||||
|
title = f'[ערר {case_number}] הוראת יו"ר — הרצה חדשה'
|
||||||
|
description = (
|
||||||
|
'הוראת יו"ר שנשלחה כ"הרצה חדשה" מטאב הסוכנים. קרא את מצב התיק, טפל בהוראה '
|
||||||
|
"בסגנון דפנה, ומסור disposition תקין בסיום.\n\n---\n\n" + instruction
|
||||||
|
)
|
||||||
|
child_resp = await pc_request(
|
||||||
|
"POST",
|
||||||
|
f"/api/issues/{main_issue_id}/children",
|
||||||
|
json={
|
||||||
|
"title": title,
|
||||||
|
"description": description,
|
||||||
|
"status": "in_progress",
|
||||||
|
"priority": "medium",
|
||||||
|
"assigneeAgentId": ceo_id,
|
||||||
|
},
|
||||||
|
raise_on_error=True,
|
||||||
|
)
|
||||||
|
sub_issue = child_resp.json()
|
||||||
|
sub_issue_id = sub_issue["id"]
|
||||||
|
|
||||||
|
try:
|
||||||
|
conn = await asyncpg.connect(PAPERCLIP_DB_URL)
|
||||||
|
try:
|
||||||
|
await _link_case_to_issue(conn, sub_issue_id, case_number)
|
||||||
|
finally:
|
||||||
|
await conn.close()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("plugin_state link failed for new-run sub_issue=%s: %s", sub_issue_id, e)
|
||||||
|
|
||||||
|
await pc_request(
|
||||||
|
"POST",
|
||||||
|
f"/api/agents/{ceo_id}/wakeup",
|
||||||
|
json={
|
||||||
|
"source": "on_demand",
|
||||||
|
"triggerDetail": "manual",
|
||||||
|
"reason": f"chair_new_run_{case_number}",
|
||||||
|
"payload": {"issueId": sub_issue_id, "case_number": case_number},
|
||||||
|
},
|
||||||
|
raise_on_error=True,
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"CEO new-run for case %s: child_issue=%s ceo=%s", case_number, sub_issue_id, ceo_id,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"issue_id": sub_issue_id,
|
||||||
|
"identifier": sub_issue.get("identifier", ""),
|
||||||
|
"main_issue_id": main_issue_id,
|
||||||
|
"ceo_id": ceo_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# Substring of the generation child-issue title per action (see _ceo_action_brief).
|
# Substring of the generation child-issue title per action (see _ceo_action_brief).
|
||||||
GENERATION_TITLE_SUBSTR = {
|
GENERATION_TITLE_SUBSTR = {
|
||||||
"party_claims_summary": "סיכום-מנהלים",
|
"party_claims_summary": "סיכום-מנהלים",
|
||||||
|
|||||||
Reference in New Issue
Block a user