Files
plugin-legal-ai/src/ui/LegalCaseTab.tsx
Chaim Marcus f70023fccc feat(ui): Phase 2 — issue detail tab + dashboard widget
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>
2026-05-26 14:04:40 +00:00

249 lines
6.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}