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:
2026-05-26 14:04:40 +00:00
parent 0ca7831c53
commit f70023fccc
10 changed files with 1199 additions and 4 deletions

27
esbuild.ui.config.mjs Normal file
View File

@@ -0,0 +1,27 @@
import { build } from "esbuild";
import { createPluginBundlerPresets } from "@paperclipai/plugin-sdk/bundlers";
import { fileURLToPath } from "node:url";
import { dirname } from "node:path";
const __dirname = dirname(fileURLToPath(import.meta.url));
const presets = createPluginBundlerPresets({
pluginRoot: __dirname,
uiEntry: "src/ui/index.tsx",
outdir: "dist",
sourcemap: true,
minify: false,
});
if (!presets.esbuild.ui) {
throw new Error("UI preset missing — check createPluginBundlerPresets input");
}
await build({
...presets.esbuild.ui,
// Ensure JSX runtime is bundled-resolved at host runtime through React peer.
jsx: "automatic",
logLevel: "info",
});
console.log("[esbuild] UI bundle written to dist/ui/index.js");

515
package-lock.json generated
View File

@@ -8,11 +8,14 @@
"name": "@marcusgroup/plugin-legal-ai", "name": "@marcusgroup/plugin-legal-ai",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@paperclipai/plugin-sdk": "^2026.525.0" "@paperclipai/plugin-sdk": "^2026.525.0",
"react": "^19.0.0"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "2.4.11", "@biomejs/biome": "2.4.11",
"@types/node": "^25.5.0", "@types/node": "^25.5.0",
"@types/react": "^19.0.0",
"esbuild": "^0.28.0",
"typescript": "^6.0.2" "typescript": "^6.0.2"
} }
}, },
@@ -179,6 +182,448 @@
"node": ">=14.21.3" "node": ">=14.21.3"
} }
}, },
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz",
"integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz",
"integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz",
"integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz",
"integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz",
"integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz",
"integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz",
"integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz",
"integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz",
"integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz",
"integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz",
"integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz",
"integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz",
"integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz",
"integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz",
"integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz",
"integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz",
"integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz",
"integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz",
"integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz",
"integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz",
"integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz",
"integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz",
"integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz",
"integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz",
"integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz",
"integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@paperclipai/plugin-sdk": { "node_modules/@paperclipai/plugin-sdk": {
"version": "2026.525.0", "version": "2026.525.0",
"resolved": "https://registry.npmjs.org/@paperclipai/plugin-sdk/-/plugin-sdk-2026.525.0.tgz", "resolved": "https://registry.npmjs.org/@paperclipai/plugin-sdk/-/plugin-sdk-2026.525.0.tgz",
@@ -219,6 +664,74 @@
"undici-types": "~7.18.0" "undici-types": "~7.18.0"
} }
}, },
"node_modules/@types/react": {
"version": "19.2.15",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz",
"integrity": "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.2.2"
}
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true,
"license": "MIT"
},
"node_modules/esbuild": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz",
"integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.28.0",
"@esbuild/android-arm": "0.28.0",
"@esbuild/android-arm64": "0.28.0",
"@esbuild/android-x64": "0.28.0",
"@esbuild/darwin-arm64": "0.28.0",
"@esbuild/darwin-x64": "0.28.0",
"@esbuild/freebsd-arm64": "0.28.0",
"@esbuild/freebsd-x64": "0.28.0",
"@esbuild/linux-arm": "0.28.0",
"@esbuild/linux-arm64": "0.28.0",
"@esbuild/linux-ia32": "0.28.0",
"@esbuild/linux-loong64": "0.28.0",
"@esbuild/linux-mips64el": "0.28.0",
"@esbuild/linux-ppc64": "0.28.0",
"@esbuild/linux-riscv64": "0.28.0",
"@esbuild/linux-s390x": "0.28.0",
"@esbuild/linux-x64": "0.28.0",
"@esbuild/netbsd-arm64": "0.28.0",
"@esbuild/netbsd-x64": "0.28.0",
"@esbuild/openbsd-arm64": "0.28.0",
"@esbuild/openbsd-x64": "0.28.0",
"@esbuild/openharmony-arm64": "0.28.0",
"@esbuild/sunos-x64": "0.28.0",
"@esbuild/win32-arm64": "0.28.0",
"@esbuild/win32-ia32": "0.28.0",
"@esbuild/win32-x64": "0.28.0"
}
},
"node_modules/react": {
"version": "19.2.6",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz",
"integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/typescript": { "node_modules/typescript": {
"version": "6.0.2", "version": "6.0.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz",

View File

@@ -7,7 +7,9 @@
"manifest": "dist/manifest.js" "manifest": "dist/manifest.js"
}, },
"scripts": { "scripts": {
"build": "tsc", "build": "tsc && node esbuild.ui.config.mjs",
"build:worker": "tsc",
"build:ui": "node esbuild.ui.config.mjs",
"dev": "paperclip-plugin-dev-server", "dev": "paperclip-plugin-dev-server",
"format": "biome format --write src/", "format": "biome format --write src/",
"format:check": "biome format src/", "format:check": "biome format src/",
@@ -15,11 +17,14 @@
"biome:fix": "biome check --write src/" "biome:fix": "biome check --write src/"
}, },
"dependencies": { "dependencies": {
"@paperclipai/plugin-sdk": "^2026.525.0" "@paperclipai/plugin-sdk": "^2026.525.0",
"react": "^19.0.0"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "2.4.11", "@biomejs/biome": "2.4.11",
"@types/node": "^25.5.0", "@types/node": "^25.5.0",
"@types/react": "^19.0.0",
"esbuild": "^0.28.0",
"typescript": "^6.0.2" "typescript": "^6.0.2"
} }
} }

View File

@@ -26,9 +26,12 @@ export default {
"companies.read", "companies.read",
"projects.read", "projects.read",
"webhooks.receive", "webhooks.receive",
"ui.detailTab.register",
"ui.dashboardWidget.register",
] as const, ] as const,
entrypoints: { entrypoints: {
worker: "dist/worker.js", worker: "dist/worker.js",
ui: "dist/ui",
}, },
instanceConfigSchema: { instanceConfigSchema: {
type: "object" as const, type: "object" as const,
@@ -184,4 +187,21 @@ export default {
"מקבל עדכוני סטטוס מ-legal-ai ומפרסם תגובה על ה-issue המקושר", "מקבל עדכוני סטטוס מ-legal-ai ומפרסם תגובה על ה-issue המקושר",
}, },
], ],
ui: {
slots: [
{
type: "detailTab" as const,
id: "legal-case-tab",
displayName: "ערר",
exportName: "LegalCaseTab",
entityTypes: ["issue" as const],
},
{
type: "dashboardWidget" as const,
id: "legal-cases-widget",
displayName: "תיקי ערר",
exportName: "LegalCasesWidget",
},
],
},
}; };

248
src/ui/LegalCaseTab.tsx Normal file
View File

@@ -0,0 +1,248 @@
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>
);
}

132
src/ui/LegalCasesWidget.tsx Normal file
View File

@@ -0,0 +1,132 @@
import type { PluginWidgetProps } from "@paperclipai/plugin-sdk/ui";
import { usePluginData } from "@paperclipai/plugin-sdk/ui/hooks";
import type { DashboardStats } from "./types.js";
const widgetStyle: React.CSSProperties = {
padding: 12,
fontFamily:
"system-ui, -apple-system, 'Segoe UI', 'Noto Sans Hebrew', sans-serif",
color: "inherit",
};
const headingStyle: React.CSSProperties = {
margin: "0 0 8px 0",
fontSize: 15,
fontWeight: 600,
};
const totalStyle: React.CSSProperties = {
fontSize: 22,
fontWeight: 700,
margin: "4px 0",
};
const ulStyle: React.CSSProperties = {
listStyle: "none",
paddingInlineStart: 0,
margin: 0,
display: "grid",
gridTemplateColumns: "1fr max-content",
rowGap: 2,
fontSize: 13,
};
const labelStyle: React.CSSProperties = {
color: "rgba(127,127,127,0.85)",
};
const STATUS_LABELS: Record<string, string> = {
new: "תיק חדש",
uploading: "העלאת מסמכים",
processing: "עיבוד מסמכים",
documents_ready: "מסמכים מוכנים",
outcome_set: "תוצאה הוזנה",
brainstorming: "סיעור מוחות",
direction_approved: "כיוון אושר",
drafting: "כתיבה",
qa_review: "בדיקת איכות",
drafted: "טיוטה מוכנה",
exported: "DOCX נוצר",
reviewed: "נבדק",
final: "סופי",
};
export function LegalCasesWidget(_props: PluginWidgetProps) {
const { data, loading, error } = usePluginData<DashboardStats | null>(
"legal-dashboard-stats",
{},
);
if (loading) {
return (
<div dir="rtl" style={widgetStyle}>
<h3 style={headingStyle}>תיקי ערר</h3>
<p style={labelStyle}>טוען</p>
</div>
);
}
if (error) {
return (
<div dir="rtl" style={widgetStyle}>
<h3 style={headingStyle}>תיקי ערר</h3>
<p style={{ color: "crimson", fontSize: 13 }}>{error.message}</p>
</div>
);
}
if (!data) {
return (
<div dir="rtl" style={widgetStyle}>
<h3 style={headingStyle}>תיקי ערר</h3>
<p style={labelStyle}>אין נתונים זמינים.</p>
</div>
);
}
const entries = Object.entries(data.byStatus).sort(([, a], [, b]) => b - a);
return (
<div dir="rtl" style={widgetStyle}>
<h3 style={headingStyle}>תיקי ערר</h3>
<div style={totalStyle}>{data.totalCases}</div>
<div style={{ fontSize: 12, color: "rgba(127,127,127,0.85)" }}>
סה"כ תיקים פעילים
</div>
<hr
style={{
margin: "8px 0",
border: 0,
borderTop: "1px solid rgba(127,127,127,0.25)",
}}
/>
{entries.length > 0 ? (
<ul style={ulStyle}>
{entries.map(([status, count]) => (
<li
key={status}
style={{ display: "contents" }}
aria-label={`${STATUS_LABELS[status] ?? status}: ${count}`}
>
<span style={labelStyle}>{STATUS_LABELS[status] ?? status}</span>
<span>{count}</span>
</li>
))}
</ul>
) : (
<p style={labelStyle}>אין תיקים פעילים.</p>
)}
<hr
style={{
margin: "8px 0",
border: 0,
borderTop: "1px solid rgba(127,127,127,0.25)",
}}
/>
<p style={{ margin: 0, fontSize: 13 }}>
<span style={labelStyle}>פעילות שבועית: </span>
<strong>{data.weekActivity}</strong>
</p>
</div>
);
}

10
src/ui/index.tsx Normal file
View File

@@ -0,0 +1,10 @@
/**
* UI bundle entrypoint for the legal-ai Paperclip plugin.
*
* Each named export here corresponds to a `slot.exportName` in the manifest's
* `ui.slots` declaration. The host loads this module as an ES module and
* mounts the matching component into its slot.
*/
export { LegalCasesWidget } from "./LegalCasesWidget.js";
export { LegalCaseTab } from "./LegalCaseTab.js";

72
src/ui/types.ts Normal file
View File

@@ -0,0 +1,72 @@
/**
* Shared types for the legal-ai plugin UI bundle.
*
* Mirrors a minimal subset of the legal-ai backend response shapes so the UI
* components can type their props/data without pulling the full LegalApi
* client (which is a worker-side dependency).
*/
export interface LegalCaseSummary {
case_number: string;
title: string;
status: string;
practice_area?: string | null;
appeal_subtype?: string | null;
proceeding_type?: string | null;
updated_at?: string | null;
archived_at?: string | null;
}
export interface LegalCaseDetails {
id: string;
case_number: string;
title: string;
status: string;
practice_area?: string | null;
appeal_subtype?: string | null;
appellants?: string[];
respondents?: string[];
subject?: string;
property_address?: string;
expected_outcome?: string;
[key: string]: unknown;
}
export interface LegalArgument {
id?: string;
party: string;
argument_title?: string;
argument_body?: string;
claim_index?: number;
[key: string]: unknown;
}
export interface LegalArgumentsResponse {
case_number: string;
total: number;
by_party: Record<string, LegalArgument[]>;
arguments: LegalArgument[];
}
export interface PrecedentRow {
id: string;
citation: string;
section_id?: string | null;
practice_area?: string | null;
[key: string]: unknown;
}
export interface MissingPrecedentRow {
id: string;
citation: string;
status: string;
legal_topic?: string | null;
cited_by_party?: string | null;
[key: string]: unknown;
}
export interface DashboardStats {
byStatus: Record<string, number>;
weekActivity: number;
totalCases: number;
}

View File

@@ -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"); ctx.logger.info("Legal AI plugin ready");
}, },

View File

@@ -8,7 +8,9 @@
"strict": true, "strict": true,
"esModuleInterop": true, "esModuleInterop": true,
"skipLibCheck": true, "skipLibCheck": true,
"declaration": true "declaration": true,
"jsx": "react-jsx",
"lib": ["ES2022", "DOM", "DOM.Iterable"]
}, },
"include": ["src"] "include": ["src"]
} }