feat(agents): mirror Paperclip interactions in case page
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 47s

Surface issue_thread_interactions (ask_user_questions / request_confirmation /
suggest_tasks) directly inside legal-ai's case detail feed so the user can
answer agent prompts without switching to Paperclip's UI.

Backend (FastAPI):
- paperclip_client.py: 4 new helpers — get_issue_interactions (DB),
  respond_to_interaction / accept_interaction / reject_interaction (REST).
- app.py: extends GET /api/cases/{case_number}/agents to include
  `interactions`, and adds POST /api/cases/{case_number}/agents/interaction-response
  routing to /respond, /accept, /reject in Paperclip.
- paperclip_client.py: also pulls existing httpx calls onto the centralized
  pc_request helper (paperclip_api.py) for consistent auth + run-id headers.

Frontend (web-ui, Next.js 16 + TanStack Query):
- agents.ts: Interaction / InteractionPayload / InteractionStatus types,
  useSubmitInteraction mutation hook (invalidates the activity query).
- agent-activity-feed.tsx: InteractionCard renders radio (single) /
  checkbox (multi) for ask_user_questions, accept/reject + reason for
  request_confirmation, task selection for suggest_tasks. Resolved
  interactions show a read-only summary. Cards are interleaved with
  comments by created_at, so the feed reads chronologically.

Paperclip auto-wakes the issue assignee on a successful response
(queueResolvedInteractionContinuationWakeup) — no explicit wakeup needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-04 16:40:45 +00:00
parent 82b29510f2
commit d0994704cf
5 changed files with 870 additions and 59 deletions

View File

@@ -5,8 +5,18 @@ 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 {
useAgentActivity,
useSendComment,
useSubmitInteraction,
} from "@/lib/api/agents";
import type {
Interaction,
InteractionPayload,
InteractionQuestion,
InteractionTask,
PaperclipComment,
} from "@/lib/api/agents";
import { toast } from "sonner";
import {
Bot,
@@ -15,6 +25,9 @@ import {
Loader2,
MessageSquare,
Clock,
CheckCircle2,
XCircle,
HelpCircle,
} from "lucide-react";
/* ── Role → color mapping ────────────────────────────────────── */
@@ -153,6 +166,463 @@ function CommentCard({
);
}
/* ── 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 mr-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 ml-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 ml-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 ml-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 ml-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 ml-1" />
)}
אישור משימות נבחרות ({selected.size})
</Button>
</div>
</div>
);
}
function InteractionCard({
interaction,
caseNumber,
issueMap,
}: {
interaction: Interaction;
caseNumber: string;
issueMap: Map<string, string>;
}) {
const submit = useSubmitInteraction(caseNumber);
const identifier = issueMap.get(interaction.issue_id) ?? "";
const isPending = interaction.status === "pending";
const summary = summaryAnswer(interaction);
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("שגיאה בשליחת התשובה"),
},
);
};
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">
<span className="text-sm font-semibold text-navy">
{interaction.title || "שאלה לסוכן"}
</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>
) : (
<ResolvedBadge status={interaction.status} />
)}
{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(interaction.resolved_at ?? interaction.created_at)}
</span>
</div>
{interaction.summary && (
<div className="text-xs text-ink-faint mb-2">{interaction.summary}</div>
)}
{isPending ? (
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
) : summary ? (
<div className="text-sm">
<Markdown content={summary} />
</div>
) : null}
</div>
</div>
);
}
/* ── Main Feed ───────────────────────────────────────────────── */
export function AgentActivityFeed({
@@ -173,11 +643,12 @@ export function AgentActivityFeed({
}
}
// Auto-scroll on new comments
// 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]);
}, [commentCount, interactionCount]);
const handleSend = () => {
if (!body.trim()) return;
@@ -224,6 +695,25 @@ export function AgentActivityFeed({
}
const comments = data.comments ?? [];
const interactions = data.interactions ?? [];
// Unified, time-sorted feed: comments + interactions interleaved.
type FeedItem =
| { kind: "comment"; at: number; comment: PaperclipComment }
| { kind: "interaction"; at: number; interaction: Interaction };
const feed: FeedItem[] = [
...comments.map<FeedItem>((c) => ({
kind: "comment",
at: c.created_at ? new Date(c.created_at).getTime() : 0,
comment: c,
})),
...interactions.map<FeedItem>((i) => ({
kind: "interaction",
at: i.created_at ? new Date(i.created_at).getTime() : 0,
interaction: i,
})),
].sort((a, b) => a.at - b.at);
// 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.
@@ -246,9 +736,9 @@ export function AgentActivityFeed({
))}
</div>
{/* Comments stream */}
{/* Comments + interactions stream */}
<div className="flex-1 overflow-y-auto max-h-[500px] space-y-1 px-1">
{comments.length === 0 ? (
{feed.length === 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" />
@@ -261,9 +751,22 @@ export function AgentActivityFeed({
</div>
)
) : (
comments.map((c) => (
<CommentCard key={c.id} comment={c} issueMap={issueMap} />
))
feed.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 ref={endRef} />
</div>

View File

@@ -42,10 +42,80 @@ export type PaperclipAgent = {
last_heartbeat_at: string | null;
};
export type InteractionKind =
| "ask_user_questions"
| "request_confirmation"
| "suggest_tasks";
export type InteractionStatus =
| "pending"
| "answered"
| "accepted"
| "rejected"
| "expired"
| "failed";
export type InteractionOption = {
id: string;
label: string;
description?: string | null;
};
export type InteractionQuestion = {
id: string;
prompt: string;
selectionMode?: "single" | "multi";
required?: boolean;
options: InteractionOption[];
};
export type InteractionTask = {
clientKey: string;
parentClientKey?: string | null;
title: string;
description?: string | null;
};
/** Free-form payload — shape depends on `kind`. Common fields surfaced for the
* UI; everything else is preserved on the wire. */
export type InteractionPayload = {
version?: number;
submitLabel?: string;
acceptLabel?: string;
rejectLabel?: string;
questions?: InteractionQuestion[];
tasks?: InteractionTask[];
body?: string;
[key: string]: unknown;
};
export type Interaction = {
id: string;
issue_id: string;
kind: InteractionKind;
status: InteractionStatus;
title: string | null;
summary: string | null;
payload: InteractionPayload;
result: Record<string, unknown> | null;
created_at: string | null;
resolved_at: string | null;
};
export type AgentActivityResponse = {
issues: PaperclipIssue[];
comments: PaperclipComment[];
agents: PaperclipAgent[];
interactions: Interaction[];
};
export type InteractionAction = "respond" | "accept" | "reject";
export type InteractionSubmitVars = {
issue_id: string;
interaction_id: string;
action: InteractionAction;
payload: Record<string, unknown>;
};
// ── Query Keys ───────────────────────────────────────────────────
@@ -85,3 +155,19 @@ export function useSendComment(caseNumber: string | undefined) {
},
});
}
export function useSubmitInteraction(caseNumber: string | undefined) {
const qc = useQueryClient();
return useMutation({
mutationFn: (vars: InteractionSubmitVars) =>
apiRequest<Interaction>(
`/api/cases/${caseNumber}/agents/interaction-response`,
{ method: "POST", body: vars },
),
onSuccess: () => {
if (caseNumber) {
qc.invalidateQueries({ queryKey: agentKeys.activity(caseNumber) });
}
},
});
}