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>
This commit is contained in:
166
src/worker.ts
166
src/worker.ts
@@ -842,6 +842,172 @@ const plugin = definePlugin({
|
||||
}
|
||||
});
|
||||
|
||||
// ── Data handlers (UI bridge) ──────────────────────────────────
|
||||
// These back `usePluginData(key, params)` calls from the React UI bundle
|
||||
// in `dist/ui/index.js`. Errors are caught and rethrown with a clear
|
||||
// message so the UI shows them through PluginBridgeError.
|
||||
|
||||
// Resolve the legal-ai case number linked to an issue via plugin state.
|
||||
async function resolveCaseNumber(issueId: string): Promise<string | null> {
|
||||
const value = await ctx.state.get({
|
||||
scopeKind: "issue",
|
||||
scopeId: issueId,
|
||||
stateKey: "legal-case-number",
|
||||
});
|
||||
return typeof value === "string" && value.length > 0 ? value : null;
|
||||
}
|
||||
|
||||
ctx.data.register("legal-case-summary", async (params) => {
|
||||
const issueId = String((params as { issueId?: string }).issueId ?? "");
|
||||
if (!issueId) return null;
|
||||
const caseNumber = await resolveCaseNumber(issueId);
|
||||
if (!caseNumber) return null;
|
||||
try {
|
||||
return await api.getCase(caseNumber);
|
||||
} catch (err) {
|
||||
ctx.logger.warn("legal-case-summary: getCase failed", {
|
||||
caseNumber,
|
||||
error: String(err),
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
ctx.data.register("legal-case-arguments", async (params) => {
|
||||
const issueId = String((params as { issueId?: string }).issueId ?? "");
|
||||
if (!issueId) return null;
|
||||
const caseNumber = await resolveCaseNumber(issueId);
|
||||
if (!caseNumber) return null;
|
||||
const config2 = await ctx.config.get();
|
||||
const apiBase =
|
||||
(config2.legalApiBaseUrl as string) ?? "http://localhost:8085";
|
||||
try {
|
||||
const resp = await ctx.http.fetch(
|
||||
`${apiBase}/api/cases/${encodeURIComponent(caseNumber)}/legal-arguments`,
|
||||
);
|
||||
if (!resp.ok) {
|
||||
ctx.logger.warn("legal-case-arguments: API error", {
|
||||
caseNumber,
|
||||
status: resp.status,
|
||||
});
|
||||
return {
|
||||
case_number: caseNumber,
|
||||
total: 0,
|
||||
by_party: {},
|
||||
arguments: [],
|
||||
};
|
||||
}
|
||||
return await resp.json();
|
||||
} catch (err) {
|
||||
ctx.logger.warn("legal-case-arguments: fetch failed", {
|
||||
caseNumber,
|
||||
error: String(err),
|
||||
});
|
||||
return {
|
||||
case_number: caseNumber,
|
||||
total: 0,
|
||||
by_party: {},
|
||||
arguments: [],
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
ctx.data.register("legal-case-precedents", async (params) => {
|
||||
const issueId = String((params as { issueId?: string }).issueId ?? "");
|
||||
if (!issueId) return [];
|
||||
const caseNumber = await resolveCaseNumber(issueId);
|
||||
if (!caseNumber) return [];
|
||||
const config2 = await ctx.config.get();
|
||||
const apiBase =
|
||||
(config2.legalApiBaseUrl as string) ?? "http://localhost:8085";
|
||||
try {
|
||||
const resp = await ctx.http.fetch(
|
||||
`${apiBase}/api/cases/${encodeURIComponent(caseNumber)}/precedents`,
|
||||
);
|
||||
if (!resp.ok) {
|
||||
ctx.logger.warn("legal-case-precedents: API error", {
|
||||
caseNumber,
|
||||
status: resp.status,
|
||||
});
|
||||
return [];
|
||||
}
|
||||
const data = await resp.json();
|
||||
// Endpoint may return a flat array or `{ precedents: [...] }`.
|
||||
if (Array.isArray(data)) return data;
|
||||
if (
|
||||
data &&
|
||||
Array.isArray((data as { precedents?: unknown[] }).precedents)
|
||||
) {
|
||||
return (data as { precedents: unknown[] }).precedents;
|
||||
}
|
||||
return [];
|
||||
} catch (err) {
|
||||
ctx.logger.warn("legal-case-precedents: fetch failed", {
|
||||
caseNumber,
|
||||
error: String(err),
|
||||
});
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
ctx.data.register("legal-case-missing-precedents", async (params) => {
|
||||
const issueId = String((params as { issueId?: string }).issueId ?? "");
|
||||
if (!issueId) return [];
|
||||
const caseNumber = await resolveCaseNumber(issueId);
|
||||
if (!caseNumber) return [];
|
||||
const config2 = await ctx.config.get();
|
||||
const apiBase =
|
||||
(config2.legalApiBaseUrl as string) ?? "http://localhost:8085";
|
||||
try {
|
||||
const url = new URL(`${apiBase}/api/missing-precedents`);
|
||||
url.searchParams.set("case_number", caseNumber);
|
||||
url.searchParams.set("status", "open");
|
||||
const resp = await ctx.http.fetch(url.toString());
|
||||
if (!resp.ok) {
|
||||
ctx.logger.warn("legal-case-missing-precedents: API error", {
|
||||
caseNumber,
|
||||
status: resp.status,
|
||||
});
|
||||
return [];
|
||||
}
|
||||
const data = (await resp.json()) as { items?: unknown[] };
|
||||
return Array.isArray(data.items) ? data.items : [];
|
||||
} catch (err) {
|
||||
ctx.logger.warn("legal-case-missing-precedents: fetch failed", {
|
||||
caseNumber,
|
||||
error: String(err),
|
||||
});
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
ctx.data.register("legal-dashboard-stats", async () => {
|
||||
try {
|
||||
const cases = await api.listCases();
|
||||
const byStatus: Record<string, number> = {};
|
||||
let weekActivity = 0;
|
||||
const weekAgoMs = Date.now() - 7 * 24 * 60 * 60 * 1000;
|
||||
for (const c of cases) {
|
||||
byStatus[c.status] = (byStatus[c.status] ?? 0) + 1;
|
||||
const updated = (c as { updated_at?: string | null }).updated_at;
|
||||
if (updated) {
|
||||
const t = Date.parse(updated);
|
||||
if (!Number.isNaN(t) && t >= weekAgoMs) weekActivity += 1;
|
||||
}
|
||||
}
|
||||
return {
|
||||
byStatus,
|
||||
weekActivity,
|
||||
totalCases: cases.length,
|
||||
};
|
||||
} catch (err) {
|
||||
ctx.logger.warn("legal-dashboard-stats: listCases failed", {
|
||||
error: String(err),
|
||||
});
|
||||
return { byStatus: {}, weekActivity: 0, totalCases: 0 };
|
||||
}
|
||||
});
|
||||
|
||||
ctx.logger.info("Legal AI plugin ready");
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user