Add Paperclip agent activity mirror to case detail page
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 3m16s
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 3m16s
New "Agents" tab in case detail shows all Paperclip agent comments,
issue status, and agent status for each case — eliminating the need
to switch between Legal-AI and Paperclip UIs.
Backend: 4 new DB query functions in paperclip_client.py (issues,
comments, agents, post_comment) + 2 new API endpoints (GET/POST
/api/cases/{case_number}/agents). Comment posting uses Board API
with DB+wakeup fallback to ensure CEO routing.
Frontend: agents.ts hooks (10s polling), AgentActivityFeed component
(markdown timeline + comment input), AgentStatusWidget (sidebar),
4th tab in case detail page.
Also includes new-company-setup-guide.md documenting the process
for setting up the betterment levy (CMPA) company.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,8 @@ import { StatusGuide } from "@/components/cases/status-guide";
|
||||
import { StatusChanger } from "@/components/cases/status-changer";
|
||||
import { DocumentsPanel } from "@/components/cases/documents-panel";
|
||||
import { DraftsPanel } from "@/components/cases/drafts-panel";
|
||||
import { AgentActivityFeed } from "@/components/cases/agent-activity-feed";
|
||||
import { AgentStatusWidget } from "@/components/cases/agent-status-widget";
|
||||
import { UploadSheet } from "@/components/documents/upload-sheet";
|
||||
import { expectedOutcomes } from "@/lib/schemas/case";
|
||||
import { useCase, useStartWorkflow } from "@/lib/api/cases";
|
||||
@@ -86,6 +88,9 @@ export default function CaseDetailPage({
|
||||
<TabsTrigger value="drafts">
|
||||
טיוטות והערות
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="agents">
|
||||
סוכנים
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<UploadSheet caseNumber={caseNumber} />
|
||||
</div>
|
||||
@@ -153,12 +158,17 @@ export default function CaseDetailPage({
|
||||
status={data?.status}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="agents" className="mt-5">
|
||||
<AgentActivityFeed caseNumber={caseNumber} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-surface border-rule shadow-sm h-fit">
|
||||
<CardContent className="px-6 py-5">
|
||||
<CardContent className="px-6 py-5 space-y-5">
|
||||
<AgentStatusWidget caseNumber={caseNumber} />
|
||||
<h2 className="text-navy text-base mb-4">שלב בתהליך</h2>
|
||||
<WorkflowTimeline status={data?.status} />
|
||||
<StatusChanger caseNumber={caseNumber} currentStatus={data?.status} />
|
||||
|
||||
267
web-ui/src/components/cases/agent-activity-feed.tsx
Normal file
267
web-ui/src/components/cases/agent-activity-feed.tsx
Normal file
@@ -0,0 +1,267 @@
|
||||
"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",
|
||||
};
|
||||
|
||||
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";
|
||||
}
|
||||
|
||||
/* ── 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)}`} />
|
||||
{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} — {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>
|
||||
);
|
||||
}
|
||||
77
web-ui/src/components/cases/agent-status-widget.tsx
Normal file
77
web-ui/src/components/cases/agent-status-widget.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import { useAgentActivity } from "@/lib/api/agents";
|
||||
import type { PaperclipAgent } from "@/lib/api/agents";
|
||||
import { Bot } from "lucide-react";
|
||||
|
||||
/* ── Status dot colors ───────────────────────────────────────── */
|
||||
|
||||
const STATUS_DOT: Record<string, string> = {
|
||||
active: "bg-emerald-500 shadow-[0_0_6px_rgba(16,185,129,0.6)]",
|
||||
idle: "bg-gray-300",
|
||||
error: "bg-red-500",
|
||||
};
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
active: "פעיל",
|
||||
idle: "ממתין",
|
||||
error: "שגיאה",
|
||||
};
|
||||
|
||||
function statusDot(status: string) {
|
||||
return STATUS_DOT[status] ?? STATUS_DOT.idle;
|
||||
}
|
||||
|
||||
/* ── Agent row ───────────────────────────────────────────────── */
|
||||
|
||||
function AgentRow({ agent }: { agent: PaperclipAgent }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 py-1">
|
||||
<span
|
||||
className={`w-2 h-2 rounded-full flex-shrink-0 ${statusDot(agent.status)}`}
|
||||
/>
|
||||
<span className="text-xs text-ink truncate">{agent.name}</span>
|
||||
<span className="text-[10px] text-ink-faint mr-auto">
|
||||
{STATUS_LABEL[agent.status] ?? agent.status}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Widget ───────────────────────────────────────────────────── */
|
||||
|
||||
export function AgentStatusWidget({
|
||||
caseNumber,
|
||||
}: {
|
||||
caseNumber: string;
|
||||
}) {
|
||||
const { data } = useAgentActivity(caseNumber);
|
||||
|
||||
// Don't render if no Paperclip project yet
|
||||
if (!data?.issues?.length) return null;
|
||||
|
||||
const agents = data.agents ?? [];
|
||||
const activeCount = agents.filter((a) => a.status === "active").length;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5 text-xs font-medium text-navy">
|
||||
<Bot className="w-3.5 h-3.5" />
|
||||
<span>סוכנים</span>
|
||||
</div>
|
||||
{agents.length > 0 && (
|
||||
<span className="text-[10px] text-ink-faint">
|
||||
{activeCount} פעילים מתוך {agents.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-0.5">
|
||||
{agents.map((agent) => (
|
||||
<AgentRow key={agent.id} agent={agent} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
87
web-ui/src/lib/api/agents.ts
Normal file
87
web-ui/src/lib/api/agents.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Paperclip agent activity hooks — mirror agent work into Legal-AI UI.
|
||||
*/
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { apiRequest } from "./client";
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────
|
||||
|
||||
export type PaperclipIssue = {
|
||||
id: string;
|
||||
title: string;
|
||||
status: string;
|
||||
identifier: string;
|
||||
priority: string;
|
||||
assignee_name: string | null;
|
||||
started_at: string | null;
|
||||
completed_at: string | null;
|
||||
created_at: string | null;
|
||||
company_id: string;
|
||||
};
|
||||
|
||||
export type PaperclipComment = {
|
||||
id: string;
|
||||
issue_id: string;
|
||||
body: string;
|
||||
created_at: string | null;
|
||||
author_agent_id: string | null;
|
||||
author_user_id: string | null;
|
||||
agent_name: string | null;
|
||||
agent_role: string | null;
|
||||
agent_icon: string | null;
|
||||
};
|
||||
|
||||
export type PaperclipAgent = {
|
||||
id: string;
|
||||
name: string;
|
||||
role: string;
|
||||
title: string | null;
|
||||
status: string;
|
||||
icon: string | null;
|
||||
last_heartbeat_at: string | null;
|
||||
};
|
||||
|
||||
export type AgentActivityResponse = {
|
||||
issues: PaperclipIssue[];
|
||||
comments: PaperclipComment[];
|
||||
agents: PaperclipAgent[];
|
||||
};
|
||||
|
||||
// ── Query Keys ───────────────────────────────────────────────────
|
||||
|
||||
export const agentKeys = {
|
||||
activity: (caseNumber: string) =>
|
||||
["agents", "activity", caseNumber] as const,
|
||||
};
|
||||
|
||||
// ── Hooks ────────────────────────────────────────────────────────
|
||||
|
||||
export function useAgentActivity(caseNumber: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: agentKeys.activity(caseNumber ?? ""),
|
||||
queryFn: ({ signal }) =>
|
||||
apiRequest<AgentActivityResponse>(
|
||||
`/api/cases/${caseNumber}/agents`,
|
||||
{ signal },
|
||||
),
|
||||
enabled: !!caseNumber,
|
||||
refetchInterval: 10_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useSendComment(caseNumber: string | undefined) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (vars: { body: string; issue_id?: string }) =>
|
||||
apiRequest<{ comment_id: string; issue_id: string; issue_identifier: string }>(
|
||||
`/api/cases/${caseNumber}/agents/comment`,
|
||||
{ method: "POST", body: vars },
|
||||
),
|
||||
onSuccess: () => {
|
||||
if (caseNumber) {
|
||||
qc.invalidateQueries({ queryKey: agentKeys.activity(caseNumber) });
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user