feat(case-ui): agents tab v2 + dismiss/reaper for stale interactions (#215) #388
@@ -9,6 +9,7 @@ import {
|
|||||||
useAgentActivity,
|
useAgentActivity,
|
||||||
useSendComment,
|
useSendComment,
|
||||||
useSubmitInteraction,
|
useSubmitInteraction,
|
||||||
|
useDismissInteraction,
|
||||||
} from "@/lib/api/agents";
|
} from "@/lib/api/agents";
|
||||||
import type {
|
import type {
|
||||||
Interaction,
|
Interaction,
|
||||||
@@ -17,6 +18,7 @@ import type {
|
|||||||
InteractionTask,
|
InteractionTask,
|
||||||
PaperclipComment,
|
PaperclipComment,
|
||||||
PaperclipAgentStatus,
|
PaperclipAgentStatus,
|
||||||
|
PaperclipIssue,
|
||||||
} from "@/lib/api/agents";
|
} from "@/lib/api/agents";
|
||||||
import { formatRelative } from "@/lib/format-date";
|
import { formatRelative } from "@/lib/format-date";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -30,6 +32,8 @@ import {
|
|||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
XCircle,
|
XCircle,
|
||||||
HelpCircle,
|
HelpCircle,
|
||||||
|
ChevronDown,
|
||||||
|
Ban,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
/* ── Role → color mapping ────────────────────────────────────── */
|
/* ── Role → color mapping ────────────────────────────────────── */
|
||||||
@@ -111,34 +115,9 @@ function agentStatusTone(s: string) {
|
|||||||
: "bg-rule-soft text-ink-muted";
|
: "bg-rule-soft text-ink-muted";
|
||||||
}
|
}
|
||||||
|
|
||||||
function AgentRoster({
|
function AgentRoster({ agents }: { agents: PaperclipAgentStatus[] }) {
|
||||||
agents,
|
|
||||||
comments,
|
|
||||||
}: {
|
|
||||||
agents: PaperclipAgentStatus[];
|
|
||||||
comments: PaperclipComment[];
|
|
||||||
}) {
|
|
||||||
if (!agents.length) return null;
|
if (!agents.length) return null;
|
||||||
|
|
||||||
// "current activity" per agent = its most recent comment body (by name/role).
|
|
||||||
const latest = new Map<string, string>();
|
|
||||||
for (const a of agents) {
|
|
||||||
let best: PaperclipComment | null = null;
|
|
||||||
let bestAt = -1;
|
|
||||||
for (const c of comments) {
|
|
||||||
const mine =
|
|
||||||
(c.agent_name && c.agent_name === a.name) ||
|
|
||||||
(c.agent_role && c.agent_role === a.role);
|
|
||||||
if (!mine) continue;
|
|
||||||
const at = c.created_at ? new Date(c.created_at).getTime() : 0;
|
|
||||||
if (at >= bestAt) {
|
|
||||||
bestAt = at;
|
|
||||||
best = c;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (best) latest.set(a.id, best.body);
|
|
||||||
}
|
|
||||||
|
|
||||||
const activeCount = agents.filter((a) => agentIsActive(a.status)).length;
|
const activeCount = agents.filter((a) => agentIsActive(a.status)).length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -150,29 +129,29 @@ function AgentRoster({
|
|||||||
<b className="text-success font-bold">{activeCount} פעילים</b> מתוך {agents.length}
|
<b className="text-success font-bold">{activeCount} פעילים</b> מתוך {agents.length}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5">
|
{/* 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) => {
|
{agents.map((a) => {
|
||||||
const active = agentIsActive(a.status);
|
const active = agentIsActive(a.status);
|
||||||
const activity = latest.get(a.id);
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={a.id}
|
key={a.id}
|
||||||
className={`rounded-lg border p-2.5 ${active ? "border-success/40 bg-success-bg/40" : "border-rule bg-surface"}`}
|
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">
|
<div className="flex items-center gap-1.5 min-w-0">
|
||||||
<span className={`w-2 h-2 rounded-full shrink-0 ${agentStatusDot(a.status)}`} />
|
<span className={`w-1.5 h-1.5 rounded-full shrink-0 ${agentStatusDot(a.status)}`} />
|
||||||
<span className="text-[0.78rem] font-bold text-navy truncate">{a.name}</span>
|
<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
|
<span
|
||||||
className={`ms-auto text-[0.6rem] font-bold rounded-full px-1.5 py-0.5 whitespace-nowrap ${agentStatusTone(a.status)}`}
|
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}
|
{AGENT_STATUS_LABEL[a.status] ?? a.status}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[0.66rem] text-ink-muted mt-0.5">{roleLabel(a.role)}</div>
|
|
||||||
<div className="text-[0.7rem] text-ink-soft mt-1.5 line-clamp-2 leading-tight">
|
|
||||||
{activity || "—"}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
@@ -605,15 +584,21 @@ function InteractionCard({
|
|||||||
interaction,
|
interaction,
|
||||||
caseNumber,
|
caseNumber,
|
||||||
issueMap,
|
issueMap,
|
||||||
|
defaultOpen = true,
|
||||||
}: {
|
}: {
|
||||||
interaction: Interaction;
|
interaction: Interaction;
|
||||||
caseNumber: string;
|
caseNumber: string;
|
||||||
issueMap: Map<string, string>;
|
issueMap: Map<string, string>;
|
||||||
|
defaultOpen?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const submit = useSubmitInteraction(caseNumber);
|
const submit = useSubmitInteraction(caseNumber);
|
||||||
|
const dismiss = useDismissInteraction(caseNumber);
|
||||||
const identifier = issueMap.get(interaction.issue_id) ?? "";
|
const identifier = issueMap.get(interaction.issue_id) ?? "";
|
||||||
const isPending = interaction.status === "pending";
|
const isPending = interaction.status === "pending";
|
||||||
const summary = summaryAnswer(interaction);
|
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>) => {
|
const send = (action: "respond" | "accept" | "reject", payload: InteractionPayload | Record<string, unknown>) => {
|
||||||
submit.mutate(
|
submit.mutate(
|
||||||
@@ -630,6 +615,16 @@ function InteractionCard({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDismiss = () => {
|
||||||
|
dismiss.mutate(
|
||||||
|
{ issue_id: interaction.issue_id, interaction_id: interaction.id },
|
||||||
|
{
|
||||||
|
onSuccess: () => toast.success("הבקשה בוטלה"),
|
||||||
|
onError: () => toast.error("שגיאה בביטול הבקשה"),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`group relative flex gap-3 py-3 px-2 rounded-lg border transition-colors ${
|
className={`group relative flex gap-3 py-3 px-2 rounded-lg border transition-colors ${
|
||||||
@@ -652,15 +647,30 @@ function InteractionCard({
|
|||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
<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">
|
<span className="text-sm font-semibold text-navy">
|
||||||
{interaction.title || "שאלה לסוכן"}
|
{interaction.title || "שאלה לסוכן"}
|
||||||
</span>
|
</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 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>
|
</span>
|
||||||
|
</button>
|
||||||
) : (
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="text-sm font-semibold text-navy">
|
||||||
|
{interaction.title || "שאלה לסוכן"}
|
||||||
|
</span>
|
||||||
<ResolvedBadge status={interaction.status} />
|
<ResolvedBadge status={interaction.status} />
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
{identifier && (
|
{identifier && (
|
||||||
<Badge variant="outline" className="text-[10px] font-mono">
|
<Badge variant="outline" className="text-[10px] font-mono">
|
||||||
@@ -671,13 +681,31 @@ function InteractionCard({
|
|||||||
<Clock className="w-3 h-3" />
|
<Clock className="w-3 h-3" />
|
||||||
{timeAgo(interaction.resolved_at ?? interaction.created_at)}
|
{timeAgo(interaction.resolved_at ?? interaction.created_at)}
|
||||||
</span>
|
</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>
|
</div>
|
||||||
|
|
||||||
{interaction.summary && (
|
{interaction.summary && (!isPending || open) && (
|
||||||
<div className="text-xs text-ink-faint mb-2">{interaction.summary}</div>
|
<div className="text-xs text-ink-faint mb-2">{interaction.summary}</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isPending ? (
|
{isPending ? (
|
||||||
|
open ? (
|
||||||
interaction.kind === "ask_user_questions" ? (
|
interaction.kind === "ask_user_questions" ? (
|
||||||
<AskUserQuestionsForm
|
<AskUserQuestionsForm
|
||||||
interaction={interaction}
|
interaction={interaction}
|
||||||
@@ -705,6 +733,7 @@ function InteractionCard({
|
|||||||
pending={submit.isPending}
|
pending={submit.isPending}
|
||||||
/>
|
/>
|
||||||
) : null
|
) : null
|
||||||
|
) : null
|
||||||
) : summary ? (
|
) : summary ? (
|
||||||
<div className="text-sm">
|
<div className="text-sm">
|
||||||
<Markdown content={summary} />
|
<Markdown content={summary} />
|
||||||
@@ -715,6 +744,80 @@ function InteractionCard({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Main Feed ───────────────────────────────────────────────── */
|
/* ── Main Feed ───────────────────────────────────────────────── */
|
||||||
|
|
||||||
export function AgentActivityFeed({
|
export function AgentActivityFeed({
|
||||||
@@ -792,25 +895,42 @@ export function AgentActivityFeed({
|
|||||||
// NOT repeated in the timeline below.
|
// NOT repeated in the timeline below.
|
||||||
const pending = interactions.filter((i) => i.status === "pending");
|
const pending = interactions.filter((i) => i.status === "pending");
|
||||||
|
|
||||||
// Unified, time-sorted feed: comments + interactions interleaved.
|
// Group the timeline by issue, each an accordion (mockup 18i v2). Comments +
|
||||||
type FeedItem =
|
// resolved interactions; pending ones are surfaced above and not repeated.
|
||||||
| { kind: "comment"; at: number; comment: PaperclipComment }
|
const itemsByIssue = new Map<string, FeedItem[]>();
|
||||||
| { kind: "interaction"; at: number; interaction: Interaction };
|
const pushItem = (issueId: string, item: FeedItem) => {
|
||||||
|
const arr = itemsByIssue.get(issueId);
|
||||||
const feed: FeedItem[] = [
|
if (arr) arr.push(item);
|
||||||
...comments.map<FeedItem>((c) => ({
|
else itemsByIssue.set(issueId, [item]);
|
||||||
|
};
|
||||||
|
for (const c of comments) {
|
||||||
|
pushItem(c.issue_id, {
|
||||||
kind: "comment",
|
kind: "comment",
|
||||||
at: c.created_at ? new Date(c.created_at).getTime() : 0,
|
at: c.created_at ? new Date(c.created_at).getTime() : 0,
|
||||||
comment: c,
|
comment: c,
|
||||||
})),
|
});
|
||||||
...interactions
|
}
|
||||||
.filter((i) => i.status !== "pending")
|
for (const i of interactions) {
|
||||||
.map<FeedItem>((i) => ({
|
if (i.status === "pending") continue;
|
||||||
|
pushItem(i.issue_id, {
|
||||||
kind: "interaction",
|
kind: "interaction",
|
||||||
at: i.created_at ? new Date(i.created_at).getTime() : 0,
|
at: i.created_at ? new Date(i.created_at).getTime() : 0,
|
||||||
interaction: i,
|
interaction: i,
|
||||||
})),
|
});
|
||||||
].sort((a, b) => a.at - b.at);
|
}
|
||||||
|
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
|
// 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.
|
// we should NOT show the "agents are working, waiting for report" spinner.
|
||||||
@@ -820,11 +940,11 @@ export function AgentActivityFeed({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
{/* agent roster — who's doing what right now (mockup 18i) */}
|
{/* agent roster — who's who, compact one-row (mockup 18i v2) */}
|
||||||
<AgentRoster agents={data.agents ?? []} comments={comments} />
|
<AgentRoster agents={data.agents ?? []} />
|
||||||
|
|
||||||
{/* pending interactions surfaced to the top — the actual forms, not a
|
{/* pending interactions surfaced to the top — each an accordion, first
|
||||||
pointer (mockup 18i — awaiting you). Excluded from the timeline. */}
|
open (mockup 18i v2 — awaiting you). 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="mb-3 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">
|
||||||
@@ -834,33 +954,29 @@ export function AgentActivityFeed({
|
|||||||
{pending.length}
|
{pending.length}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{pending.map((i) => (
|
{pending.map((i, idx) => (
|
||||||
<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}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Issue summary bar */}
|
{/* Timeline header */}
|
||||||
<div className="flex items-center gap-2 px-2 py-2 border-b border-rule mb-2 flex-wrap">
|
<div className="flex items-center gap-2 px-2 py-2 border-b border-rule mb-1">
|
||||||
{data.issues.map((iss) => (
|
<span className="text-[0.82rem] font-bold text-navy">פעילות לפי משימה</span>
|
||||||
<Badge
|
<span className="text-[0.72rem] text-ink-muted">
|
||||||
key={iss.id}
|
{data.issues.length} משימות · {totalFeedItems} הודעות
|
||||||
variant={iss.status === "done" ? "secondary" : "default"}
|
</span>
|
||||||
className="text-[11px] font-mono"
|
|
||||||
>
|
|
||||||
{iss.identifier} — {issueStatusLabel(iss.status)}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Comments + interactions stream */}
|
{/* Per-issue accordions */}
|
||||||
<div className="flex-1 overflow-y-auto max-h-[500px] space-y-1 px-1">
|
<div className="flex-1 overflow-y-auto max-h-[500px]">
|
||||||
{feed.length === 0 ? (
|
{totalFeedItems === 0 ? (
|
||||||
hasActiveIssue ? (
|
hasActiveIssue ? (
|
||||||
<div className="text-center py-8 text-ink-faint text-sm">
|
<div className="text-center py-8 text-ink-faint text-sm">
|
||||||
<Loader2 className="w-5 h-5 animate-spin mx-auto mb-2" />
|
<Loader2 className="w-5 h-5 animate-spin mx-auto mb-2" />
|
||||||
@@ -873,22 +989,19 @@ export function AgentActivityFeed({
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
feed.map((item) =>
|
issueGroups.map(({ iss, items }, idx) => (
|
||||||
item.kind === "comment" ? (
|
<IssueGroup
|
||||||
<CommentCard
|
key={iss.id}
|
||||||
key={`c-${item.comment.id}`}
|
issue={iss}
|
||||||
comment={item.comment}
|
items={items}
|
||||||
issueMap={issueMap}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<InteractionCard
|
|
||||||
key={`i-${item.interaction.id}`}
|
|
||||||
interaction={item.interaction}
|
|
||||||
caseNumber={caseNumber}
|
caseNumber={caseNumber}
|
||||||
issueMap={issueMap}
|
issueMap={issueMap}
|
||||||
|
defaultOpen={
|
||||||
|
idx === 0 ||
|
||||||
|
(iss.status !== "done" && iss.status !== "cancelled")
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
),
|
))
|
||||||
)
|
|
||||||
)}
|
)}
|
||||||
<div ref={endRef} />
|
<div ref={endRef} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -180,6 +180,26 @@ export function useSubmitInteraction(caseNumber: string | undefined) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Dismiss a pending interaction WITHOUT waking the issue assignee — used for
|
||||||
|
* stale/duplicate questions. Backend marks it `cancelled` directly (the
|
||||||
|
* `wake_assignee` continuation policy means a normal resolve would wake the
|
||||||
|
* agent; dismiss must not). */
|
||||||
|
export function useDismissInteraction(caseNumber: string | undefined) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (vars: { issue_id: string; interaction_id: string }) =>
|
||||||
|
apiRequest<{ ok: boolean; cancelled: boolean }>(
|
||||||
|
`/api/cases/${caseNumber}/agents/interaction-dismiss`,
|
||||||
|
{ method: "POST", body: vars },
|
||||||
|
),
|
||||||
|
onSuccess: () => {
|
||||||
|
if (caseNumber) {
|
||||||
|
qc.invalidateQueries({ queryKey: agentKeys.activity(caseNumber) });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export type AgentResetResult = {
|
export type AgentResetResult = {
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
reassigned_issues: { id: string; identifier: string }[];
|
reassigned_issues: { id: string; identifier: string }[];
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ from web.paperclip_client import (
|
|||||||
COMPANIES as PAPERCLIP_COMPANIES,
|
COMPANIES as PAPERCLIP_COMPANIES,
|
||||||
accept_interaction as pc_accept_interaction,
|
accept_interaction as pc_accept_interaction,
|
||||||
archive_project as pc_archive_project,
|
archive_project as pc_archive_project,
|
||||||
|
cancel_interaction as pc_cancel_interaction,
|
||||||
cancel_run as pc_cancel_run,
|
cancel_run as pc_cancel_run,
|
||||||
create_project as pc_create_project,
|
create_project as pc_create_project,
|
||||||
create_workflow_issue as pc_create_workflow_issue,
|
create_workflow_issue as pc_create_workflow_issue,
|
||||||
@@ -50,6 +51,7 @@ from web.paperclip_client import (
|
|||||||
get_run_log as pc_get_run_log,
|
get_run_log as pc_get_run_log,
|
||||||
list_live_runs as pc_list_live_runs,
|
list_live_runs as pc_list_live_runs,
|
||||||
post_comment as pc_post_comment,
|
post_comment as pc_post_comment,
|
||||||
|
reap_stale_interactions as pc_reap_stale_interactions,
|
||||||
reject_interaction as pc_reject_interaction,
|
reject_interaction as pc_reject_interaction,
|
||||||
reset_agent_session as pc_reset_agent_session,
|
reset_agent_session as pc_reset_agent_session,
|
||||||
reset_case_agents as pc_reset_case_agents,
|
reset_case_agents as pc_reset_case_agents,
|
||||||
@@ -111,6 +113,8 @@ __all__ = [
|
|||||||
"pc_accept_interaction",
|
"pc_accept_interaction",
|
||||||
"pc_reject_interaction",
|
"pc_reject_interaction",
|
||||||
"pc_respond_to_interaction",
|
"pc_respond_to_interaction",
|
||||||
|
"pc_cancel_interaction",
|
||||||
|
"pc_reap_stale_interactions",
|
||||||
# agent-run observability + control (live view + smart management)
|
# agent-run observability + control (live view + smart management)
|
||||||
"pc_list_live_runs",
|
"pc_list_live_runs",
|
||||||
"pc_get_run_log",
|
"pc_get_run_log",
|
||||||
|
|||||||
47
web/app.py
47
web/app.py
@@ -57,6 +57,7 @@ from web.agent_platform_port import (
|
|||||||
get_project_url,
|
get_project_url,
|
||||||
pc_accept_interaction,
|
pc_accept_interaction,
|
||||||
pc_archive_project,
|
pc_archive_project,
|
||||||
|
pc_cancel_interaction,
|
||||||
pc_cancel_run,
|
pc_cancel_run,
|
||||||
pc_create_project,
|
pc_create_project,
|
||||||
pc_create_workflow_issue,
|
pc_create_workflow_issue,
|
||||||
@@ -69,6 +70,7 @@ from web.agent_platform_port import (
|
|||||||
pc_get_run_log,
|
pc_get_run_log,
|
||||||
pc_list_live_runs,
|
pc_list_live_runs,
|
||||||
pc_post_comment,
|
pc_post_comment,
|
||||||
|
pc_reap_stale_interactions,
|
||||||
pc_reject_interaction,
|
pc_reject_interaction,
|
||||||
pc_request,
|
pc_request,
|
||||||
pc_reset_agent_session,
|
pc_reset_agent_session,
|
||||||
@@ -101,17 +103,36 @@ PROGRESS_TTL_SECONDS = 300
|
|||||||
_progress = ProgressStore(config.REDIS_URL, ttl_seconds=PROGRESS_TTL_SECONDS)
|
_progress = ProgressStore(config.REDIS_URL, ttl_seconds=PROGRESS_TTL_SECONDS)
|
||||||
|
|
||||||
|
|
||||||
|
async def _interaction_reaper_loop():
|
||||||
|
"""Safety-net reaper for stranded pending interactions (TaskMaster #215).
|
||||||
|
|
||||||
|
Every 15 min, cancel pending interactions on closed (done/cancelled) issues
|
||||||
|
so the chair's "awaiting you" count never accumulates dead questions.
|
||||||
|
"""
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
await asyncio.sleep(900)
|
||||||
|
await pc_reap_stale_interactions()
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
logger.warning("interaction reaper sweep failed", exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
await db.init_schema()
|
await db.init_schema()
|
||||||
sync_task = asyncio.create_task(git_sync.sweep_loop())
|
sync_task = asyncio.create_task(git_sync.sweep_loop())
|
||||||
|
reaper_task = asyncio.create_task(_interaction_reaper_loop())
|
||||||
try:
|
try:
|
||||||
yield
|
yield
|
||||||
finally:
|
finally:
|
||||||
sync_task.cancel()
|
sync_task.cancel()
|
||||||
|
reaper_task.cancel()
|
||||||
|
for _t in (sync_task, reaper_task):
|
||||||
try:
|
try:
|
||||||
await sync_task
|
await _t
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
pass
|
pass
|
||||||
await db.close_pool()
|
await db.close_pool()
|
||||||
@@ -4411,6 +4432,30 @@ async def api_post_interaction_response(
|
|||||||
raise HTTPException(502, f"שגיאת Paperclip: {e}")
|
raise HTTPException(502, f"שגיאת Paperclip: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
class InteractionDismissRequest(BaseModel):
|
||||||
|
issue_id: str
|
||||||
|
interaction_id: str
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/cases/{case_number}/agents/interaction-dismiss")
|
||||||
|
async def api_dismiss_interaction(
|
||||||
|
case_number: str, req: InteractionDismissRequest,
|
||||||
|
):
|
||||||
|
"""Dismiss a pending interaction WITHOUT waking the issue assignee.
|
||||||
|
|
||||||
|
For stale/duplicate questions (TaskMaster #215). Unlike interaction-response,
|
||||||
|
this does not resolve via Paperclip's wake_assignee path — it cancels the row
|
||||||
|
so the chair can clear noise without triggering an agent run.
|
||||||
|
"""
|
||||||
|
issues = await pc_get_case_issues(case_number)
|
||||||
|
if not any(i["id"] == req.issue_id for i in issues):
|
||||||
|
raise HTTPException(404, f"Issue {req.issue_id} לא שייך לתיק {case_number}")
|
||||||
|
result = await pc_cancel_interaction(req.issue_id, req.interaction_id)
|
||||||
|
if not result.get("ok"):
|
||||||
|
raise HTTPException(404, result.get("error", "ביטול הבקשה נכשל"))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/cases/{case_number}/agents/reset")
|
@app.post("/api/cases/{case_number}/agents/reset")
|
||||||
async def api_reset_case_agents(case_number: str):
|
async def api_reset_case_agents(case_number: str):
|
||||||
"""Reset stuck agents for a case.
|
"""Reset stuck agents for a case.
|
||||||
|
|||||||
@@ -899,6 +899,69 @@ async def reject_interaction(
|
|||||||
return resp.json()
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
async def cancel_interaction(issue_id: str, interaction_id: str) -> dict:
|
||||||
|
"""Dismiss a pending interaction WITHOUT waking the issue assignee.
|
||||||
|
|
||||||
|
For stale/duplicate questions (TaskMaster #215). We flip the row to
|
||||||
|
``cancelled`` directly rather than calling Paperclip's resolve endpoints:
|
||||||
|
those carry the interaction's ``wake_assignee`` continuation policy and would
|
||||||
|
re-wake the agent. A direct status flip has no side effects — the table has
|
||||||
|
no triggers (verified) and nothing reaps ``cancelled`` rows.
|
||||||
|
"""
|
||||||
|
conn = await asyncpg.connect(PAPERCLIP_DB_URL)
|
||||||
|
try:
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
"""UPDATE issue_thread_interactions
|
||||||
|
SET status='cancelled', resolved_at=now(),
|
||||||
|
resolved_by_user_id=$3, updated_at=now()
|
||||||
|
WHERE id=$1::uuid AND issue_id=$2::uuid AND status='pending'
|
||||||
|
RETURNING id, status""",
|
||||||
|
interaction_id, issue_id, CHAIM_USER_ID,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
await conn.close()
|
||||||
|
if not row:
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"cancelled": False,
|
||||||
|
"error": "interaction not found or already resolved",
|
||||||
|
}
|
||||||
|
return {"ok": True, "cancelled": True, "id": str(row["id"]), "status": row["status"]}
|
||||||
|
|
||||||
|
|
||||||
|
async def reap_stale_interactions() -> dict:
|
||||||
|
"""Cancel pending interactions stranded on closed issues (done/cancelled).
|
||||||
|
|
||||||
|
Safety-net reaper (TaskMaster #215): a question on a closed issue can never
|
||||||
|
be answered and only inflates the chair's "awaiting you" count — this was the
|
||||||
|
dominant source of the pending pile-up. Scoped to the two legal-ai companies.
|
||||||
|
Direct status flip — no wakeups, no side effects.
|
||||||
|
"""
|
||||||
|
company_ids = list(COMPANIES.values())
|
||||||
|
conn = await asyncpg.connect(PAPERCLIP_DB_URL)
|
||||||
|
try:
|
||||||
|
rows = await conn.fetch(
|
||||||
|
"""UPDATE issue_thread_interactions it
|
||||||
|
SET status='cancelled', resolved_at=now(),
|
||||||
|
resolved_by_user_id=$2, updated_at=now()
|
||||||
|
FROM issues i
|
||||||
|
WHERE i.id = it.issue_id
|
||||||
|
AND it.status='pending'
|
||||||
|
AND it.company_id = ANY($1::uuid[])
|
||||||
|
AND i.status IN ('done', 'cancelled')
|
||||||
|
RETURNING it.id""",
|
||||||
|
company_ids, CHAIM_USER_ID,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
await conn.close()
|
||||||
|
if rows:
|
||||||
|
logger.info(
|
||||||
|
"reap_stale_interactions: cancelled %d stranded pending interactions",
|
||||||
|
len(rows),
|
||||||
|
)
|
||||||
|
return {"ok": True, "cancelled": len(rows)}
|
||||||
|
|
||||||
|
|
||||||
# Singleton project for the precedent-library extraction queue. One issue per
|
# Singleton project for the precedent-library extraction queue. One issue per
|
||||||
# uploaded precedent — assigned to the CEO who runs the local-MCP extractor.
|
# uploaded precedent — assigned to the CEO who runs the local-MCP extractor.
|
||||||
_LIBRARY_PROJECT_NAME = "ספריית פסיקה — תור חילוץ"
|
_LIBRARY_PROJECT_NAME = "ספריית פסיקה — תור חילוץ"
|
||||||
|
|||||||
Reference in New Issue
Block a user