Plugin now mounts two React components inside Paperclip via the
SDK's UI slot mechanism. Both are read-only views over data the
plugin worker fetches from legal-ai on demand.
## Slots
* **LegalCaseTab** (type: detailTab, entityTypes: ["issue"])
Mounted as a "ערר" tab on every issue page. Shows case summary
(status / practice_area / appeal_subtype), legal_arguments
grouped by party (עוררים/ועדה/משיבה/מבקשי היתר), attached
precedents, and open missing_precedents.
* **LegalCasesWidget** (type: dashboardWidget)
Dashboard tile with case counts by status + 7-day activity.
## Worker handlers (ctx.data.register)
Five handlers added at the end of setup() — all read-only over the
existing legal-ai HTTP API, all wrapped in try/catch so a transient
failure shows a placeholder instead of crashing the host:
- legal-case-summary → /api/cases/{n}/details
- legal-case-arguments → /api/cases/{n}/legal-arguments
- legal-case-precedents → /api/cases/{n}/precedents
- legal-case-missing-precedents → /api/missing-precedents?case_number=&status=open
- legal-dashboard-stats → in-memory aggregation over /api/cases
case_number is resolved from plugin state (scopeKind=issue,
stateKey=legal-case-number) — populated by legal_case_create.
## Build pipeline
- esbuild.ui.config.mjs uses createPluginBundlerPresets from the SDK
to build src/ui/index.tsx → dist/ui/index.js (13.5kb, react +
@paperclipai/plugin-sdk/ui externalized)
- package.json: build = "build:worker" (tsc) + "build:ui" (esbuild)
- tsconfig.json: jsx=react-jsx, lib += DOM
- New deps: react@19, @types/react, esbuild
## Manifest
- capabilities += ui.detailTab.register, ui.dashboardWidget.register
- entrypoints.ui = "dist/ui"
- ui.slots declared with entityTypes (not "entities" — fixed against
PluginUiSlotDeclaration validator)
## Verified
- tsc + esbuild + biome clean
- Plugin re-installs (20 capabilities) and activates with worker
+ 8 tools + 3 jobs + 1 webhook + 2 event subs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
249 lines
6.5 KiB
TypeScript
249 lines
6.5 KiB
TypeScript
import type { PluginDetailTabProps } from "@paperclipai/plugin-sdk/ui";
|
||
import { usePluginData } from "@paperclipai/plugin-sdk/ui/hooks";
|
||
import type {
|
||
LegalArgument,
|
||
LegalArgumentsResponse,
|
||
LegalCaseDetails,
|
||
MissingPrecedentRow,
|
||
PrecedentRow,
|
||
} from "./types.js";
|
||
|
||
const containerStyle: React.CSSProperties = {
|
||
padding: 16,
|
||
fontFamily:
|
||
"system-ui, -apple-system, 'Segoe UI', 'Noto Sans Hebrew', sans-serif",
|
||
color: "inherit",
|
||
};
|
||
|
||
const sectionStyle: React.CSSProperties = {
|
||
marginBottom: 24,
|
||
};
|
||
|
||
const headingStyle: React.CSSProperties = {
|
||
fontSize: 16,
|
||
fontWeight: 600,
|
||
margin: "0 0 8px 0",
|
||
borderBottom: "1px solid rgba(127,127,127,0.25)",
|
||
paddingBottom: 4,
|
||
};
|
||
|
||
const metaListStyle: React.CSSProperties = {
|
||
display: "grid",
|
||
gridTemplateColumns: "max-content 1fr",
|
||
columnGap: 12,
|
||
rowGap: 4,
|
||
fontSize: 14,
|
||
margin: 0,
|
||
};
|
||
|
||
const detailsBlockStyle: React.CSSProperties = {
|
||
margin: "6px 0",
|
||
padding: "6px 10px",
|
||
background: "rgba(127,127,127,0.08)",
|
||
borderRadius: 6,
|
||
};
|
||
|
||
const ulStyle: React.CSSProperties = {
|
||
listStyle: "none",
|
||
paddingInlineStart: 0,
|
||
margin: 0,
|
||
};
|
||
|
||
const liStyle: React.CSSProperties = {
|
||
padding: "4px 0",
|
||
fontSize: 14,
|
||
borderBottom: "1px dotted rgba(127,127,127,0.2)",
|
||
};
|
||
|
||
const mutedStyle: React.CSSProperties = {
|
||
color: "rgba(127,127,127,0.8)",
|
||
fontSize: 13,
|
||
};
|
||
|
||
function PartySection({
|
||
party,
|
||
args,
|
||
}: {
|
||
party: string;
|
||
args: LegalArgument[];
|
||
}) {
|
||
if (!args || args.length === 0) return null;
|
||
return (
|
||
<div style={{ marginBottom: 12 }}>
|
||
<strong style={{ fontSize: 14 }}>{party}</strong>
|
||
{args.map((a, idx) => {
|
||
const key = a.id ?? `${party}-${a.claim_index ?? idx}`;
|
||
const title =
|
||
a.argument_title ||
|
||
(a.argument_body || "").slice(0, 80) ||
|
||
"(ללא כותרת)";
|
||
const body = a.argument_body || "";
|
||
return (
|
||
<details key={key} style={detailsBlockStyle}>
|
||
<summary style={{ cursor: "pointer", fontSize: 14 }}>
|
||
{title}
|
||
</summary>
|
||
{body && (
|
||
<p style={{ margin: "6px 0 0 0", whiteSpace: "pre-wrap" }}>
|
||
{body}
|
||
</p>
|
||
)}
|
||
</details>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function LegalCaseTab({ context }: PluginDetailTabProps) {
|
||
const issueId = context.entityId;
|
||
|
||
const summary = usePluginData<LegalCaseDetails | null>("legal-case-summary", {
|
||
issueId,
|
||
});
|
||
const args = usePluginData<LegalArgumentsResponse | null>(
|
||
"legal-case-arguments",
|
||
{ issueId },
|
||
);
|
||
const precedents = usePluginData<PrecedentRow[] | null>(
|
||
"legal-case-precedents",
|
||
{ issueId },
|
||
);
|
||
const missing = usePluginData<MissingPrecedentRow[] | null>(
|
||
"legal-case-missing-precedents",
|
||
{ issueId },
|
||
);
|
||
|
||
if (summary.loading) {
|
||
return (
|
||
<div dir="rtl" style={containerStyle}>
|
||
<p>טוען נתוני תיק…</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (summary.error) {
|
||
return (
|
||
<div dir="rtl" style={containerStyle}>
|
||
<p style={{ color: "crimson" }}>
|
||
שגיאה בטעינת התיק: {summary.error.message}
|
||
</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (!summary.data) {
|
||
return (
|
||
<div dir="rtl" style={containerStyle}>
|
||
<p style={mutedStyle}>
|
||
אין תיק ערר מקושר ל-issue זה. ניתן ליצור תיק חדש דרך CEO או דרך כלי
|
||
המערכת.
|
||
</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const caseDetails = summary.data;
|
||
const byParty = args.data?.by_party ?? {};
|
||
const argsTotal = args.data?.total ?? 0;
|
||
const precedentsList = precedents.data ?? [];
|
||
const missingList = missing.data ?? [];
|
||
|
||
return (
|
||
<div dir="rtl" style={containerStyle}>
|
||
<section style={sectionStyle}>
|
||
<h2 style={{ margin: "0 0 8px 0", fontSize: 20 }}>
|
||
תיק {caseDetails.case_number}
|
||
</h2>
|
||
<div style={metaListStyle}>
|
||
<span style={mutedStyle}>כותרת:</span>
|
||
<span>{caseDetails.title}</span>
|
||
<span style={mutedStyle}>סטטוס:</span>
|
||
<span>
|
||
<code>{caseDetails.status}</code>
|
||
</span>
|
||
{caseDetails.practice_area && (
|
||
<>
|
||
<span style={mutedStyle}>תחום:</span>
|
||
<span>{caseDetails.practice_area}</span>
|
||
</>
|
||
)}
|
||
{caseDetails.appeal_subtype && (
|
||
<>
|
||
<span style={mutedStyle}>סוג ערר:</span>
|
||
<span>{caseDetails.appeal_subtype}</span>
|
||
</>
|
||
)}
|
||
{caseDetails.expected_outcome && (
|
||
<>
|
||
<span style={mutedStyle}>תוצאה צפויה:</span>
|
||
<span>{caseDetails.expected_outcome}</span>
|
||
</>
|
||
)}
|
||
</div>
|
||
</section>
|
||
|
||
<section style={sectionStyle}>
|
||
<h3 style={headingStyle}>טיעונים משפטיים ({argsTotal})</h3>
|
||
{args.loading && <p style={mutedStyle}>טוען טיעונים…</p>}
|
||
{args.error && (
|
||
<p style={{ color: "crimson" }}>שגיאה: {args.error.message}</p>
|
||
)}
|
||
{!args.loading && argsTotal === 0 && (
|
||
<p style={mutedStyle}>לא חולצו עדיין טיעונים מהמסמכים.</p>
|
||
)}
|
||
{Object.entries(byParty).map(([party, list]) => (
|
||
<PartySection key={party} party={party} args={list} />
|
||
))}
|
||
</section>
|
||
|
||
<section style={sectionStyle}>
|
||
<h3 style={headingStyle}>פסיקה מצורפת ({precedentsList.length})</h3>
|
||
{precedents.loading && <p style={mutedStyle}>טוען פסיקה…</p>}
|
||
{precedents.error && (
|
||
<p style={{ color: "crimson" }}>שגיאה: {precedents.error.message}</p>
|
||
)}
|
||
{!precedents.loading && precedentsList.length === 0 && (
|
||
<p style={mutedStyle}>לא צורפה עדיין פסיקה לתיק.</p>
|
||
)}
|
||
{precedentsList.length > 0 && (
|
||
<ul style={ulStyle}>
|
||
{precedentsList.map((p) => (
|
||
<li key={p.id} style={liStyle}>
|
||
<strong>{p.citation}</strong>
|
||
{p.practice_area && (
|
||
<span style={mutedStyle}> — {p.practice_area}</span>
|
||
)}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
</section>
|
||
|
||
<section style={sectionStyle}>
|
||
<h3 style={headingStyle}>פסיקה חסרה ({missingList.length})</h3>
|
||
{missing.loading && <p style={mutedStyle}>טוען רשימת חסרים…</p>}
|
||
{missing.error && (
|
||
<p style={{ color: "crimson" }}>שגיאה: {missing.error.message}</p>
|
||
)}
|
||
{!missing.loading && missingList.length === 0 && (
|
||
<p style={mutedStyle}>אין פסיקה חסרה פתוחה בתיק זה.</p>
|
||
)}
|
||
{missingList.length > 0 && (
|
||
<ul style={ulStyle}>
|
||
{missingList.map((m) => (
|
||
<li key={m.id} style={liStyle}>
|
||
<strong>{m.citation}</strong>
|
||
<span style={mutedStyle}> — {m.status}</span>
|
||
{m.legal_topic && (
|
||
<span style={mutedStyle}> ({m.legal_topic})</span>
|
||
)}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
</section>
|
||
</div>
|
||
);
|
||
}
|