create-safest-tools 0.6.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/README.md +1 -1
  2. package/package.json +2 -2
  3. package/src/config.mjs +2 -2
  4. package/template/README.md +1 -1
  5. package/template/console/analytics/AnalyticsWorkspace.tsx +49 -36
  6. package/template/console/analytics/controller.ts +10 -2
  7. package/template/console/analytics/types.ts +7 -0
  8. package/template/console/appeals/AppealFollowup.tsx +171 -0
  9. package/template/console/appeals/AppealWorkspace.tsx +33 -4
  10. package/template/console/appeals/RecordAppealDialog.tsx +42 -0
  11. package/template/console/appeals/appeal-corrections.css +29 -0
  12. package/template/console/appeals/controller.ts +40 -14
  13. package/template/console/appeals/events.ts +1 -0
  14. package/template/console/appeals/types.ts +6 -0
  15. package/template/console/appeals/useAppealDraftGuard.ts +12 -0
  16. package/template/console/assistant/AssistantWorkspace.tsx +28 -5
  17. package/template/console/command/CommandCentre.tsx +12 -6
  18. package/template/console/command/controller.ts +13 -9
  19. package/template/console/command/presentation.ts +1 -6
  20. package/template/console/command/store.ts +1 -0
  21. package/template/console/command/types.ts +1 -0
  22. package/template/console/configuration/ConfigurationBuilders.tsx +101 -0
  23. package/template/console/configuration/ConfigurationDialog.tsx +28 -13
  24. package/template/console/configuration/ConfigurationWorkspace.tsx +21 -8
  25. package/template/console/configuration/types.ts +1 -1
  26. package/template/console/lib/priority.ts +13 -0
  27. package/template/console/operations/CaseEscalations.tsx +142 -0
  28. package/template/console/operations/OperationsWorkspace.tsx +2 -0
  29. package/template/console/operations/case-escalations.css +20 -0
  30. package/template/console/people/PeopleWorkspace.tsx +8 -4
  31. package/template/console/people/PersonAccessDialog.tsx +40 -0
  32. package/template/console/people/types.ts +1 -0
  33. package/template/console/profile/ProfileWorkspace.tsx +1 -1
  34. package/template/console/registry/IntegrationSetup.tsx +41 -0
  35. package/template/console/registry/IntegrationVerification.tsx +54 -0
  36. package/template/console/registry/RegistryDetailsDialog.tsx +1 -1
  37. package/template/console/registry/RegistryDialog.tsx +34 -4
  38. package/template/console/registry/RegistryWorkspace.tsx +10 -5
  39. package/template/console/registry/integration-verification.css +25 -0
  40. package/template/console/reports/CaseActions.tsx +81 -0
  41. package/template/console/reports/CaseContinuity.tsx +27 -0
  42. package/template/console/reports/CaseNotices.tsx +48 -0
  43. package/template/console/reports/CaseOwnership.tsx +78 -0
  44. package/template/console/reports/CaseReviewTasks.tsx +148 -0
  45. package/template/console/reports/InvestigationBrief.tsx +29 -0
  46. package/template/console/reports/ReportDrawer.tsx +109 -67
  47. package/template/console/reports/ReportWorkspace.tsx +11 -12
  48. package/template/console/reports/controller.ts +96 -49
  49. package/template/console/reports/events.ts +9 -2
  50. package/template/console/reports/load-inbox.ts +17 -0
  51. package/template/console/reports/private-drafts.ts +32 -0
  52. package/template/console/reports/store.ts +2 -2
  53. package/template/console/reports/types.ts +41 -2
  54. package/template/console/reports/use-editing-access.ts +13 -0
  55. package/template/console/settings/SettingsWorkspace.tsx +1 -1
  56. package/template/console/shell/WorkspaceShell.tsx +55 -29
  57. package/template/console/shell/WorkspaceUpdateNotice.tsx +48 -0
  58. package/template/console/shell/controller.ts +22 -8
  59. package/template/console/shell/navigation.ts +30 -39
  60. package/template/console/workflows/CreatePlaybookDialog.tsx +73 -0
  61. package/template/console/workflows/PlaybookExamples.tsx +160 -0
  62. package/template/console/workflows/PlaybookFields.tsx +109 -0
  63. package/template/console/workflows/PlaybookGuideEditor.tsx +125 -0
  64. package/template/console/workflows/PlaybookHistory.tsx +106 -0
  65. package/template/console/workflows/WorkflowDialogs.tsx +2 -1
  66. package/template/console/workflows/WorkflowStudio.tsx +108 -39
  67. package/template/console/workflows/WorkflowWorkspace.tsx +9 -7
  68. package/template/console/workflows/templates.ts +3 -3
  69. package/template/console/workflows/types.ts +11 -0
  70. package/template/migrations/0002_playbook_publication_evidence.sql +36 -0
  71. package/template/migrations/0003_action_confirmation.sql +24 -0
  72. package/template/migrations/0004_notice_recovery.sql +22 -0
  73. package/template/migrations/0005_appeal_continuity.sql +17 -0
  74. package/template/migrations/0006_case_ownership.sql +23 -0
  75. package/template/migrations/0007_integration_verification.sql +21 -0
  76. package/template/migrations/0008_case_escalations.sql +135 -0
  77. package/template/migrations/0009_participant_continuation.sql +61 -0
  78. package/template/migrations/0010_appeal_corrections.sql +41 -0
  79. package/template/npm-shrinkwrap.json +8036 -0
  80. package/template/package.json +25 -17
  81. package/template/public/_headers +16 -1
  82. package/template/public/console/auth-shell.js +18701 -11800
  83. package/template/public/console/release.json +1 -0
  84. package/template/public/console/workspace-features.css +2 -0
  85. package/template/public/embed/embed.css +3 -0
  86. package/template/public/embed/embed.js +19 -1
  87. package/template/public/embed/index.html +2 -0
  88. package/template/public/index.html +9 -6
  89. package/template/public/participant.css +2 -0
  90. package/template/public/participant.html +13 -0
  91. package/template/public/participant.js +111 -0
  92. package/template/public/public-report.js +21 -1
  93. package/template/public/report/index.html +1 -1
  94. package/template/public/styles.css +329 -10
  95. package/template/public/workspace.css +124 -0
  96. package/template/scripts/reports-plan.mjs +1 -1
  97. package/template/shared/case-escalations.ts +31 -0
  98. package/template/shared/integration-progress.ts +27 -0
  99. package/template/shared/notice-inputs.ts +3 -0
  100. package/template/shared/playbook-resources.ts +14 -0
  101. package/template/shared/playbooks.ts +249 -0
  102. package/template/src/agent-executor.ts +25 -8
  103. package/template/src/ai-observability.ts +14 -13
  104. package/template/src/ai-registry-validation.ts +1 -1
  105. package/template/src/appeal-continuity.ts +75 -0
  106. package/template/src/appeal-correction-state.ts +46 -0
  107. package/template/src/appeal-corrections.ts +112 -0
  108. package/template/src/appeal-decisions.ts +85 -0
  109. package/template/src/assistant.ts +3 -4
  110. package/template/src/case-escalations.ts +223 -0
  111. package/template/src/case-investigator.ts +179 -0
  112. package/template/src/component-executor.ts +3 -2
  113. package/template/src/configuration-registry.ts +4 -1
  114. package/template/src/database-readiness.ts +8 -3
  115. package/template/src/human-tasks.ts +152 -55
  116. package/template/src/index.ts +178 -18
  117. package/template/src/installation-admin.ts +2 -1
  118. package/template/src/integration-progress.ts +45 -0
  119. package/template/src/integration-verification.ts +249 -0
  120. package/template/src/investigation-evidence.ts +95 -0
  121. package/template/src/investigator-fixture.ts +30 -0
  122. package/template/src/notice-delivery.ts +19 -0
  123. package/template/src/notice-recovery.ts +81 -0
  124. package/template/src/operator-scope.ts +31 -0
  125. package/template/src/participant-access.ts +163 -0
  126. package/template/src/participant-case.ts +87 -0
  127. package/template/src/participant-customer-access.ts +93 -0
  128. package/template/src/participant-recovery.ts +43 -0
  129. package/template/src/participant-routes.ts +67 -0
  130. package/template/src/playbook-resources.ts +58 -0
  131. package/template/src/report-admin.ts +9 -2
  132. package/template/src/report-auth.ts +1 -28
  133. package/template/src/report-delivery.ts +136 -129
  134. package/template/src/report-email-template.ts +5 -2
  135. package/template/src/report-governance.ts +42 -45
  136. package/template/src/report-operations.ts +16 -63
  137. package/template/src/report-ownership.ts +64 -0
  138. package/template/src/report-repository.ts +16 -7
  139. package/template/src/report-types.ts +1 -0
  140. package/template/src/report-workflow.ts +181 -274
  141. package/template/src/workflow-actions.ts +248 -156
  142. package/template/src/workflow-compiler.ts +63 -12
  143. package/template/src/workflow-dynamic-runtime.ts +18 -7
  144. package/template/src/workflow-effects.ts +95 -18
  145. package/template/src/workflow-event-transport.ts +16 -0
  146. package/template/src/workflow-platform-types.ts +3 -0
  147. package/template/src/workflow-platform-validation.ts +23 -1
  148. package/template/src/workflow-repository.ts +131 -28
  149. package/template/src/workflow-runs.ts +9 -4
  150. package/template/src/workflow-simulator.ts +7 -4
  151. package/template/src/workspace-governance.ts +6 -4
  152. package/template/tsconfig.json +1 -0
  153. package/template/vite.console.config.ts +30 -2
  154. package/template/worker-configuration.d.ts +3 -2
@@ -1,4 +1,4 @@
1
- import { errorMessage, requestJson } from "../lib/http";
1
+ import { ApiRequestError, errorMessage, requestJson } from "../lib/http";
2
2
  import { dispatchReportRefresh } from "../reports/events";
3
3
  import { getShellSnapshot, subscribeToShell } from "../shell/store";
4
4
  import { appealEvents } from "./events";
@@ -19,7 +19,11 @@ const initialSnapshot = (): AppealWorkspaceSnapshot => ({
19
19
  });
20
20
 
21
21
  let actorId = "";
22
+ let accessSignature = "";
22
23
  let started = false;
24
+ let listVersion = 0;
25
+ let detailVersion = 0;
26
+ let decisionRetry: {appealId:string;actorId:string;key:string} | null = null;
23
27
 
24
28
  function permissions(snapshot: AppealWorkspaceSnapshot): AppealWorkspaceSnapshot {
25
29
  const session = getShellSnapshot().session;
@@ -27,6 +31,7 @@ function permissions(snapshot: AppealWorkspaceSnapshot): AppealWorkspaceSnapshot
27
31
  }
28
32
 
29
33
  async function loadAppeals(): Promise<void> {
34
+ const version = ++listVersion;
30
35
  const session = getShellSnapshot().session;
31
36
  if (!session?.permissions.reviewAppeals) {
32
37
  setAppealWorkspaceSnapshot(permissions({ ...initialSnapshot(), loading: false }));
@@ -36,26 +41,39 @@ async function loadAppeals(): Promise<void> {
36
41
  setAppealWorkspaceSnapshot(permissions({ ...current, error: "", refreshing: !current.loading }));
37
42
  try {
38
43
  const result = await requestJson<AppealListResponse>("/v1/admin/appeals?limit=100");
44
+ if (version !== listVersion || session.actor.id !== getShellSnapshot().session?.actor.id) return;
39
45
  setAppealWorkspaceSnapshot((snapshot) => {
40
46
  const appeals = result.appeals || [];
41
47
  const selected = snapshot.detail.appealId ? appeals.find((appeal) => appeal.id === snapshot.detail.appealId) || null : null;
42
- return permissions({ ...snapshot, appeals, detail: selected ? { ...snapshot.detail, appeal: selected } : snapshot.detail });
48
+ return permissions({ ...snapshot, appeals, detail: selected ? { ...snapshot.detail, appeal: {...snapshot.detail.appeal,...selected} } : snapshot.detail });
43
49
  });
44
50
  } catch (cause) {
45
- setAppealWorkspaceSnapshot((snapshot) => ({ ...snapshot, error: errorMessage(cause, "Appeals could not be loaded.") }));
51
+ if (version === listVersion) setAppealWorkspaceSnapshot((snapshot) => ({ ...snapshot, error: errorMessage(cause, "Appeals could not be loaded.") }));
46
52
  } finally {
47
- setAppealWorkspaceSnapshot((snapshot) => permissions({ ...snapshot, loading: false, refreshing: false }));
53
+ if (version === listVersion) setAppealWorkspaceSnapshot((snapshot) => permissions({ ...snapshot, loading: false, refreshing: false }));
48
54
  }
49
55
  }
50
56
 
51
- function openAppeal(appealId: string): void {
57
+ async function openAppeal(appealId: string, refresh = false): Promise<void> {
58
+ const current=getAppealWorkspaceSnapshot();
59
+ if (!current.canReview) return;
60
+ if (current.detail.appealId && current.detail.appealId !== appealId && !window.dispatchEvent(new Event(appealEvents.beforeClose,{cancelable:true}))) return;
61
+ const version=++detailVersion;
52
62
  setAppealWorkspaceSnapshot((snapshot) => {
53
63
  const appeal = snapshot.appeals.find((candidate) => candidate.id === appealId) || null;
54
- return appeal ? permissions({ ...snapshot, detail: { appealId, appeal, error: "", pendingAction: null } }) : snapshot;
64
+ return refresh ? snapshot : permissions({ ...snapshot, detail: { appealId, appeal: snapshot.detail.appealId===appealId?snapshot.detail.appeal:appeal, error: "", pendingAction: null } });
55
65
  });
66
+ try {
67
+ const result = await requestJson<{ appeal: AppealRecord }>(`/v1/admin/appeals/${encodeURIComponent(appealId)}`);
68
+ if(version===detailVersion) setAppealWorkspaceSnapshot((snapshot) => snapshot.detail.appealId === appealId ? { ...snapshot, detail: { ...snapshot.detail, appeal: result.appeal } } : snapshot);
69
+ } catch (cause) {
70
+ if(version===detailVersion) setAppealWorkspaceSnapshot((snapshot) => snapshot.detail.appealId === appealId ? { ...snapshot, detail: { ...snapshot.detail, ...(cause instanceof ApiRequestError && [401,403,404].includes(cause.status)?{appeal:null}:{}), error: errorMessage(cause, "Appeal details could not be loaded.") } } : snapshot);
71
+ }
56
72
  }
57
73
 
58
- function closeAppeal(): void {
74
+ function closeAppeal(committed = false): void {
75
+ if (!committed && !window.dispatchEvent(new Event(appealEvents.beforeClose,{cancelable:true}))) return;
76
+ detailVersion++;
59
77
  setAppealWorkspaceSnapshot((snapshot) => permissions({ ...snapshot, detail: { appealId: null, appeal: null, error: "", pendingAction: null } }));
60
78
  }
61
79
 
@@ -67,6 +85,7 @@ async function claimAppeal(): Promise<void> {
67
85
  try {
68
86
  await requestJson(`/v1/admin/appeals/${encodeURIComponent(appeal.id)}/claim`, { method: "POST" });
69
87
  await loadAppeals();
88
+ await openAppeal(appeal.id);
70
89
  } catch (cause) {
71
90
  setAppealWorkspaceSnapshot((current) => ({ ...current, detail: { ...current.detail, error: errorMessage(cause, "The appeal could not be claimed.") } }));
72
91
  } finally {
@@ -78,8 +97,9 @@ async function recordDecision(input: AppealDecisionInput): Promise<void> {
78
97
  const snapshot = getAppealWorkspaceSnapshot();
79
98
  const appeal = snapshot.detail.appeal;
80
99
  const activeClaim = Boolean(appeal && appeal.reviewerId === snapshot.actorId && appeal.claimExpiresAt && Date.parse(appeal.claimExpiresAt) > Date.now());
81
- if (!appeal || !snapshot.canReview || snapshot.detail.pendingAction || !activeClaim) return;
82
- const idempotencyKey = crypto.randomUUID();
100
+ if (!appeal || !getShellSnapshot().session?.permissions.decideAppeals || snapshot.detail.pendingAction || !activeClaim) return;
101
+ if (!decisionRetry || decisionRetry.appealId!==appeal.id || decisionRetry.actorId!==snapshot.actorId) decisionRetry={appealId:appeal.id,actorId:snapshot.actorId,key:crypto.randomUUID()};
102
+ const idempotencyKey = decisionRetry.key;
83
103
  setAppealWorkspaceSnapshot({ ...snapshot, detail: { ...snapshot.detail, error: "", pendingAction: "decision" } });
84
104
  try {
85
105
  await requestJson(`/v1/admin/appeals/${encodeURIComponent(appeal.id)}/decision`, {
@@ -87,10 +107,13 @@ async function recordDecision(input: AppealDecisionInput): Promise<void> {
87
107
  headers: { "content-type": "application/json", "idempotency-key": idempotencyKey },
88
108
  body: JSON.stringify({ idempotency_key: idempotencyKey, outcome: input.outcome, rationale: input.rationale, participant_notice: input.participantNotice }),
89
109
  });
90
- closeAppeal();
110
+ decisionRetry=null;
111
+ if (getAppealWorkspaceSnapshot().detail.appealId!==appeal.id || getShellSnapshot().session?.actor.id!==snapshot.actorId) return;
112
+ closeAppeal(true);
91
113
  await loadAppeals();
92
114
  dispatchReportRefresh();
93
115
  } catch (cause) {
116
+ if (cause instanceof ApiRequestError && cause.status>=400 && cause.status<500) decisionRetry=null;
94
117
  setAppealWorkspaceSnapshot((current) => ({ ...current, detail: { ...current.detail, error: errorMessage(cause, "The appeal decision could not be recorded.") } }));
95
118
  } finally {
96
119
  setAppealWorkspaceSnapshot((current) => permissions({ ...current, detail: { ...current.detail, pendingAction: null } }));
@@ -103,19 +126,22 @@ export function startAppealController(): void {
103
126
  subscribeToShell(() => {
104
127
  const session = getShellSnapshot().session;
105
128
  const nextActorId = session?.actor.id || "";
106
- if (nextActorId === actorId) {
129
+ const nextAccessSignature = JSON.stringify({permissions:session?.permissions,queueScope:session?.queueScope});
130
+ if (nextActorId === actorId && nextAccessSignature === accessSignature) {
107
131
  setAppealWorkspaceSnapshot((snapshot) => permissions(snapshot));
108
132
  return;
109
133
  }
110
134
  actorId = nextActorId;
135
+ accessSignature = nextAccessSignature;
136
+ listVersion++; detailVersion++; decisionRetry=null;
111
137
  setAppealWorkspaceSnapshot(initialSnapshot());
112
138
  if (session) void loadAppeals();
113
139
  });
114
- window.addEventListener(appealEvents.refresh, () => { void loadAppeals(); });
140
+ window.addEventListener(appealEvents.refresh, () => { void loadAppeals(); const id=getAppealWorkspaceSnapshot().detail.appealId; if(id)void openAppeal(id,true); });
115
141
  window.addEventListener(appealEvents.open, (event) => {
116
- if (event instanceof CustomEvent && typeof event.detail?.appealId === "string") openAppeal(event.detail.appealId);
142
+ if (event instanceof CustomEvent && typeof event.detail?.appealId === "string") void openAppeal(event.detail.appealId);
117
143
  });
118
- window.addEventListener(appealEvents.close, closeAppeal);
144
+ window.addEventListener(appealEvents.close, () => closeAppeal());
119
145
  window.addEventListener(appealEvents.claim, () => { void claimAppeal(); });
120
146
  window.addEventListener(appealEvents.decision, (event) => {
121
147
  if (!(event instanceof CustomEvent) || !event.detail || !["upheld", "modified", "reversed"].includes(event.detail.outcome)) return;
@@ -5,6 +5,7 @@ export const appealEvents = {
5
5
  refresh: "safest:appeals:refresh",
6
6
  open: "safest:appeals:open",
7
7
  close: "safest:appeals:close",
8
+ beforeClose: "safest:appeals:before-close",
8
9
  claim: "safest:appeals:claim",
9
10
  decision: "safest:appeals:decision",
10
11
  } as const;
@@ -3,6 +3,12 @@ export type AppealPendingAction = "claim" | "decision" | null;
3
3
 
4
4
  export interface AppealRecord {
5
5
  id: string;
6
+ reportId?: string;
7
+ originalDecisionCode?: string;
8
+ originalPolicyCode?: string;
9
+ originalRationale?: string;
10
+ originalMakerId?: string;
11
+ decisions?: Array<{ id: string; outcome: string; rationale: string; reviewerId: string; completionState: string; correctionRequired: number; correctionReference: string | null; correctionNote: string | null; correctionConfirmedAt: string | null; correctionConfirmedBy: string | null; noticeState: string | null }>;
6
12
  reportReference: string;
7
13
  appellantAudience: string;
8
14
  submittedAt: string;
@@ -0,0 +1,12 @@
1
+ import { useEffect } from "react";
2
+ import { appealEvents } from "./events";
3
+
4
+ export function useAppealDraftGuard(open:boolean,dirty:boolean,pending:boolean) {
5
+ useEffect(()=>{
6
+ if(!open)return;
7
+ const close=(event:Event)=>{if(pending || (dirty&&!window.confirm("Discard your unsent appeal changes?")))event.preventDefault();};
8
+ const unload=(event:BeforeUnloadEvent)=>{if(dirty||pending){event.preventDefault();event.returnValue="";}};
9
+ window.addEventListener(appealEvents.beforeClose,close); window.addEventListener("beforeunload",unload);
10
+ return()=>{window.removeEventListener(appealEvents.beforeClose,close);window.removeEventListener("beforeunload",unload);};
11
+ },[open,dirty,pending]);
12
+ }
@@ -1,7 +1,7 @@
1
1
  import { useEffect, useRef, useState, useSyncExternalStore, type FormEvent, type KeyboardEvent } from "react";
2
2
  import { PageHeader } from "../components/PageHeader";
3
3
  import { relativeTime } from "../lib/format";
4
- import { errorMessage, mutationHeaders, requestJson } from "../lib/http";
4
+ import { ApiRequestError, errorMessage, mutationHeaders, requestJson } from "../lib/http";
5
5
  import { dispatchOpenReport } from "../reports/events";
6
6
  import { dispatchNavigation } from "../shell/events";
7
7
  import { getShellSnapshot, subscribeToShell } from "../shell/store";
@@ -9,7 +9,7 @@ import type { AssistantHistory, AssistantMessage, AssistantSessionResponse } fro
9
9
 
10
10
  const prompts = [
11
11
  "How many phishing reports were resolved yesterday?",
12
- "List unresolved workflow failures and show their current state.",
12
+ "Find spam",
13
13
  ] as const;
14
14
  const roleNames: Record<string, string> = { reviewer: "Analyst", appeal_reviewer: "Appeal analyst", administrator: "Administrator", owner: "Infrastructure owner", auditor: "Auditor" };
15
15
  const readable = (value: string): string => value.replaceAll("_", " ").replace(/^./u, (letter) => letter.toUpperCase());
@@ -28,14 +28,33 @@ export function AssistantWorkspace() {
28
28
  const [error, setError] = useState("");
29
29
  const inputRef = useRef<HTMLTextAreaElement>(null);
30
30
  const messagesRef = useRef<HTMLDivElement>(null);
31
+ const sendingRef = useRef(false);
31
32
  const canUse = shell.session?.permissions.useAssistant === true;
32
33
 
33
34
  useEffect(() => {
35
+ let current = true;
34
36
  setSessionId("");
35
37
  setHistory(null);
36
38
  setDraft("");
37
39
  setError("");
38
- }, [shell.session?.actor.id]);
40
+ setPending("");
41
+ const actor = shell.session?.actor.id;
42
+ if (!actor || !canUse) return;
43
+ const key = `safest-assistant:${actor}`;
44
+ let saved = "";
45
+ try { saved = sessionStorage.getItem(key) || ""; } catch { /* Browser storage may be disabled. */ }
46
+ if (!saved) return;
47
+ setPending("new");
48
+ void requestJson<AssistantHistory>(`/v1/admin/assistant/sessions/${encodeURIComponent(saved)}`).then((result) => {
49
+ if (current) { setSessionId(saved); setHistory(result); }
50
+ }).catch((cause) => {
51
+ if (!current) return;
52
+ if (cause instanceof ApiRequestError && [404, 409].includes(cause.status)) {
53
+ try { sessionStorage.removeItem(key); } catch { /* Storage is optional. */ }
54
+ } else setError(errorMessage(cause, "Your conversation could not be restored. Refresh to try again."));
55
+ }).finally(() => { if (current) setPending(""); });
56
+ return () => { current = false; };
57
+ }, [shell.session?.actor.id, canUse]);
39
58
 
40
59
  useEffect(() => {
41
60
  const focus = (): void => { requestAnimationFrame(() => inputRef.current?.focus()); };
@@ -55,6 +74,7 @@ export function AssistantWorkspace() {
55
74
  try {
56
75
  const result = await requestJson<AssistantSessionResponse>("/v1/admin/assistant/sessions", { method: "POST", headers: mutationHeaders(false) });
57
76
  setSessionId(result.sessionId);
77
+ try { sessionStorage.setItem(`safest-assistant:${shell.session?.actor.id}`, result.sessionId); } catch { /* Conversation remains usable without browser storage. */ }
58
78
  setHistory({ session: { id: result.sessionId, timezone: result.timezone, expiresAt: result.expiresAt }, messages: [], toolCalls: [] });
59
79
  setDraft("");
60
80
  requestAnimationFrame(() => inputRef.current?.focus());
@@ -70,10 +90,11 @@ export function AssistantWorkspace() {
70
90
  const send = async (event: FormEvent<HTMLFormElement>): Promise<void> => {
71
91
  event.preventDefault();
72
92
  const body = draft.trim();
73
- if (!body || !canUse) return;
93
+ if (!body || !canUse || sendingRef.current || pending) return;
94
+ sendingRef.current = true;
74
95
  let activeSessionId = sessionId;
75
96
  if (!activeSessionId) activeSessionId = await createConversation() ?? "";
76
- if (!activeSessionId) return;
97
+ if (!activeSessionId) { sendingRef.current = false; return; }
77
98
  const optimistic: AssistantMessage = { id: crypto.randomUUID(), role: "user", body, citations: [], createdAt: new Date().toISOString() };
78
99
  setDraft("");
79
100
  setPending("send");
@@ -91,6 +112,7 @@ export function AssistantWorkspace() {
91
112
  setDraft(body);
92
113
  setError(errorMessage(cause, "Safest could not answer that question."));
93
114
  } finally {
115
+ sendingRef.current = false;
94
116
  setPending("");
95
117
  }
96
118
  };
@@ -103,6 +125,7 @@ export function AssistantWorkspace() {
103
125
  const onComposerKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>): void => {
104
126
  if (event.key === "Enter" && !event.shiftKey) {
105
127
  event.preventDefault();
128
+ if (sendingRef.current || pending) return;
106
129
  event.currentTarget.form?.requestSubmit();
107
130
  }
108
131
  };
@@ -1,6 +1,7 @@
1
1
  import { useSyncExternalStore } from "react";
2
2
  import { PageHeader } from "../components/PageHeader";
3
- import { dispatchOpenReport } from "../reports/events";
3
+ import { CaseEscalations } from '../operations/CaseEscalations';
4
+ import { dispatchOpenReport, dispatchReportFilters } from "../reports/events";
4
5
  import { dispatchNavigation } from "../shell/events";
5
6
  import { compactDuration } from "./presentation";
6
7
  import { getCommandSnapshot, subscribeToCommand } from "./store";
@@ -27,6 +28,10 @@ function EmptyReviewQueue() {
27
28
  }
28
29
 
29
30
  function Metrics({ metrics, loading }: { metrics: CommandMetricSnapshot; loading: boolean }) {
31
+ const openSummary = (label: string): void => {
32
+ dispatchReportFilters({ search: "", queueId: "", priority: label === "Urgent" ? "urgent" : "", age: "", state: "needs_review", sort: "priority", ownership: label === "Unassigned" ? "unassigned" : "" });
33
+ dispatchNavigation("reports");
34
+ };
30
35
  const values: Array<{ label: string; value: number; detail: string; tone: CommandStatusTone }> = [
31
36
  { label: "To review", value: metrics.awaitingReview, detail: "Ready for a decision", tone: metrics.awaitingReview ? "warning" : "positive" },
32
37
  { label: "Urgent", value: metrics.urgent, detail: "Highest priority", tone: metrics.urgent ? "danger" : "positive" },
@@ -36,7 +41,7 @@ function Metrics({ metrics, loading }: { metrics: CommandMetricSnapshot; loading
36
41
  return (
37
42
  <section className="command-signals" aria-label="Live review summary" aria-busy={loading}>
38
43
  {values.map((item) => (
39
- <button className={`command-signal ${item.tone}`} type="button" key={item.label} onClick={() => dispatchNavigation("reports")}>
44
+ item.label === "Reviewers online" ? <article className={`command-signal ${item.tone}`} key={item.label}><span>{item.label}</span><strong>{loading ? "—" : item.value}</strong><small>{item.detail}</small></article> : <button className={`command-signal ${item.tone}`} type="button" key={item.label} onClick={() => openSummary(item.label)}>
40
45
  <span>{item.label}</span><strong>{loading ? "—" : item.value}</strong><small>{item.detail}</small>
41
46
  </button>
42
47
  ))}
@@ -94,15 +99,15 @@ export function CommandCentre() {
94
99
  const summary = snapshot.loading
95
100
  ? "Loading the live review queue."
96
101
  : reviewCount
97
- ? `${reviewCount} report${reviewCount === 1 ? "" : "s"} awaiting review · oldest waiting ${compactDuration(snapshot.metrics.oldestWaitMinutes)}.`
102
+ ? `${reviewCount} case${reviewCount === 1 ? "" : "s"} awaiting review · oldest waiting ${compactDuration(snapshot.metrics.oldestWaitMinutes)}.`
98
103
  : "The review queue is clear. New work will appear here automatically.";
99
- const nextReport = snapshot.attention[0];
104
+ const nextReport = snapshot.nextReview;
100
105
 
101
106
  return (
102
107
  <div data-react-slice="command-centre">
103
- <PageHeader className="command-head" title="Command centre" description={summary}>
108
+ <PageHeader className="command-head" title="Today" description={summary}>
104
109
  <div className="surface-buttons command-actions">
105
- {nextReport ? <button className="primary" type="button" onClick={() => performAction(nextReport.action)}><svg aria-hidden="true"><use href="#i-inbox" /></svg>Review next</button> : null}
110
+ {nextReport ? <button className="primary" type="button" onClick={() => dispatchOpenReport(nextReport.reportId, true)}><svg aria-hidden="true"><use href="#i-inbox" /></svg>{nextReport.mine ? "Continue review" : "Review next"}</button> : null}
106
111
  {snapshot.permissions.useAssistant ? <button className="secondary command-assistant-action" type="button" onClick={() => dispatchNavigation("assistant")}><svg aria-hidden="true"><use href="#i-spark" /></svg>Ask Safest</button> : null}
107
112
  </div>
108
113
  </PageHeader>
@@ -112,6 +117,7 @@ export function CommandCentre() {
112
117
  ) : null}
113
118
 
114
119
  <Metrics metrics={snapshot.metrics} loading={snapshot.loading} />
120
+ <CaseEscalations compact />
115
121
 
116
122
  <div className="command-workspace-grid">
117
123
  <section className="surface command-panel review-priority-panel">
@@ -1,3 +1,4 @@
1
+ import { loadInbox } from "../reports/load-inbox";
1
2
  import { requestJson } from "../lib/http";
2
3
  import { readable, relativeTime } from "../lib/format";
3
4
  import { getOperationsWorkspaceSnapshot, subscribeToOperationsWorkspace } from "../operations/store";
@@ -19,7 +20,7 @@ interface CommandReport {
19
20
  state: string;
20
21
  priority: number;
21
22
  receivedAt: string;
22
- claim?: { reviewerId?: string } | null;
23
+ assignment?: { reviewerId?: string; analyst?: { displayName?: string } } | null;
23
24
  }
24
25
 
25
26
  interface WorkflowRun {
@@ -34,7 +35,6 @@ interface WorkflowRun {
34
35
  createdAt: string;
35
36
  }
36
37
 
37
- interface ReportsResponse { reports?: CommandReport[] }
38
38
  interface QueuesResponse { queues?: QueueRecord[] }
39
39
  interface WorkflowRunsResponse { runs?: WorkflowRun[] }
40
40
 
@@ -55,7 +55,7 @@ let refreshScheduled = false;
55
55
  let started = false;
56
56
 
57
57
  function reportDataSignature(): string {
58
- return reports.map((report) => `${report.id}:${report.state}:${report.priority}:${report.claim?.reviewerId || ""}`).sort().join("|");
58
+ return reports.map((report) => `${report.id}:${report.state}:${report.priority}:${report.assignment?.reviewerId || ""}`).sort().join("|");
59
59
  }
60
60
 
61
61
  function reportStoreSignature(): string {
@@ -112,7 +112,7 @@ function buildSnapshot(): CommandSnapshot {
112
112
  const rightBreached = slaStatus(reportAgeMinutes(right), rightTarget).breached;
113
113
  if (leftBreached !== rightBreached) return leftBreached ? -1 : 1;
114
114
  if (left.priority !== right.priority) return right.priority - left.priority;
115
- if (Boolean(left.claim) !== Boolean(right.claim)) return left.claim ? 1 : -1;
115
+ if (Boolean(left.assignment) !== Boolean(right.assignment)) return left.assignment ? 1 : -1;
116
116
  return Date.parse(left.receivedAt) - Date.parse(right.receivedAt);
117
117
  });
118
118
 
@@ -125,7 +125,7 @@ function buildSnapshot(): CommandSnapshot {
125
125
  reference: report.reference,
126
126
  queueName: queue?.name || "Unrouted",
127
127
  age: relativeTime(report.receivedAt),
128
- ownership: report.claim ? "Assigned" : "Unassigned",
128
+ ownership: report.assignment ? (report.assignment.reviewerId === actorId ? "Assigned to you" : `Assigned to ${report.assignment.analyst?.displayName || "another reviewer"}`) : "Unassigned",
129
129
  priorityLabel: priorityLabel(report.priority),
130
130
  slaLabel: sla.label,
131
131
  tone: report.priority >= 80 ? "danger" : sla.tone,
@@ -145,7 +145,7 @@ function buildSnapshot(): CommandSnapshot {
145
145
  name: queue.name,
146
146
  targetMinutes,
147
147
  openCount: queuedReports.length,
148
- unassignedCount: queuedReports.filter((report) => !report.claim).length,
148
+ unassignedCount: queuedReports.filter((report) => !report.assignment).length,
149
149
  oldestMinutes,
150
150
  breachedCount,
151
151
  pressure,
@@ -155,7 +155,7 @@ function buildSnapshot(): CommandSnapshot {
155
155
 
156
156
  const optionalSetup = new Set(setup?.optional || []);
157
157
  const incompleteSetupCount = setup?.features.filter((feature) => feature.state !== "ready" && !optionalSetup.has(feature.featureKey)).length || 0;
158
- const unassigned = needsHuman.filter((report) => !report.claim).length;
158
+ const unassigned = needsHuman.filter((report) => !report.assignment).length;
159
159
  const urgent = needsHuman.filter((report) => report.priority >= 80).length;
160
160
  const oldestWaitMinutes = needsHuman.length ? Math.max(...needsHuman.map(reportAgeMinutes)) : 0;
161
161
  const systemIssueCount = Number(incidentCount > 0) + Number(workflowIssueCount > 0) + Number(deliveryFailureCount > 0);
@@ -170,6 +170,10 @@ function buildSnapshot(): CommandSnapshot {
170
170
  oldestWaitMinutes,
171
171
  },
172
172
  attention,
173
+ nextReview: session?.permissions.claimReports ? (() => {
174
+ const next = sortedAttention.find(report => report.assignment?.reviewerId === actorId) || sortedAttention.find(report => !report.assignment);
175
+ return next ? { reportId: next.id, mine: next.assignment?.reviewerId === actorId } : null;
176
+ })() : null,
173
177
  queueLoads,
174
178
  systemHealth: {
175
179
  issueCount: systemIssueCount,
@@ -201,8 +205,8 @@ async function loadCommandData(): Promise<void> {
201
205
  loading = true;
202
206
  publish();
203
207
  const tasks: Promise<void>[] = [
204
- requestJson<ReportsResponse>("/v1/admin/reports?limit=100")
205
- .then((response) => { reports = response.reports || []; })
208
+ loadInbox<CommandReport>()
209
+ .then((response) => { reports = response; })
206
210
  .catch(() => { reports = []; }),
207
211
  requestJson<QueuesResponse>(`/v1/admin/queues${session.permissions.manageConfiguration ? "?include_archived=true" : ""}`)
208
212
  .then((response) => { queues = response.queues || []; })
@@ -1,4 +1,5 @@
1
1
  import type { CommandStatusTone } from "./types";
2
+ export { priorityLabel } from "../lib/priority";
2
3
 
3
4
  export function compactDuration(minutes: number): string {
4
5
  const rounded = Math.max(0, Math.floor(minutes));
@@ -9,12 +10,6 @@ export function compactDuration(minutes: number): string {
9
10
  return `${Math.floor(hours / 24)}d`;
10
11
  }
11
12
 
12
- export function priorityLabel(priority: number): string {
13
- if (priority >= 80) return "Urgent";
14
- if (priority >= 60) return "High";
15
- return "Standard";
16
- }
17
-
18
13
  export function slaStatus(ageMinutes: number, targetMinutes: number | null): {
19
14
  label: string;
20
15
  tone: CommandStatusTone;
@@ -8,6 +8,7 @@ let snapshot: CommandSnapshot = {
8
8
  generatedAt: new Date().toISOString(),
9
9
  metrics: { awaitingReview: 0, urgent: 0, unassigned: 0, onlineReviewers: 0, oldestWaitMinutes: 0 },
10
10
  attention: [],
11
+ nextReview: null,
11
12
  queueLoads: [],
12
13
  systemHealth: { issueCount: 0, incidentCount: 0, workflowIssueCount: 0, deliveryFailureCount: 0, activeRunCount: 0 },
13
14
  incompleteSetupCount: 0,
@@ -54,6 +54,7 @@ export interface CommandPermissions {
54
54
  }
55
55
 
56
56
  export interface CommandSnapshot {
57
+ nextReview: { reportId: string; mine: boolean } | null;
57
58
  loading: boolean;
58
59
  generatedAt: string;
59
60
  metrics: CommandMetricSnapshot;
@@ -0,0 +1,101 @@
1
+ import type { JsonObject, JsonValue, QueueSummary } from "./types";
2
+
3
+ interface BuilderProps { value: JsonObject; onChange: (value: JsonObject) => void; disabled: boolean }
4
+ const objects = (value: JsonValue | undefined): JsonObject[] => Array.isArray(value) ? value.filter((item): item is JsonObject => Boolean(item) && typeof item === "object" && !Array.isArray(item)) : [];
5
+ const string = (value: JsonValue | undefined): string => value == null ? "" : String(value);
6
+
7
+ function QueueChoice({ label, help, value, queues, onChange, disabled, humanOnly = false }: {
8
+ label: string; help: string; value: string; queues: QueueSummary[]; onChange: (value: string) => void; disabled: boolean; humanOnly?: boolean;
9
+ }) {
10
+ const choices = queues.filter(queue => queue.id === value || queue.status === "active" && (!humanOnly || queue.activePolicy?.managementMode === "human"));
11
+ return <label>{label}<small>{help}</small><select aria-label={label} required disabled={disabled} value={value} onChange={event => onChange(event.currentTarget.value)}>
12
+ <option value="" disabled>Choose a queue</option>
13
+ {value && !choices.some(queue => queue.id === value) ? <option value={value}>{value} · unavailable</option> : null}
14
+ {choices.map(queue => <option value={queue.id} key={queue.id}>{queue.name}{queue.status === "active" ? "" : ` · ${queue.status}`}</option>)}
15
+ </select></label>;
16
+ }
17
+
18
+ const fields = [["reason_code", "Report reason"], ["source", "Report source"], ["target_type", "Content type"], ["priority", "Case priority"], ["safeguard.requires_human", "Human review required"]];
19
+ const operators = [["eq", "is"], ["neq", "is not"], ["in", "is one of"], ["not_in", "is not one of"], ["contains", "contains"], ["exists", "has a value"], ["gt", "is greater than"], ["gte", "is at least"], ["lt", "is less than"], ["lte", "is at most"]];
20
+
21
+ function Condition({ value, onChange, onRemove, disabled, index }: { value: JsonObject; onChange: (value: JsonObject) => void; onRemove: () => void; disabled: boolean; index: number }) {
22
+ const field = string(value.field), operator = string(value.operator);
23
+ const list = operator === "in" || operator === "not_in";
24
+ const numeric = typeof value.value === "number" || Array.isArray(value.value) && value.value.some(item => typeof item === "number") || field === "priority" || ["gt", "gte", "lt", "lte"].includes(operator);
25
+ const boolean = typeof value.value === "boolean" || Array.isArray(value.value) && value.value.some(item => typeof item === "boolean") || field === "safeguard.requires_human";
26
+ const changeOperator = (next: string) => {
27
+ const { value: previous, ...rest } = value;
28
+ const initial = boolean ? true : numeric ? 0 : "";
29
+ onChange(next === "exists" ? { ...rest, operator: next } : { ...rest, operator: next, value: next === "in" || next === "not_in" ? Array.isArray(previous) ? previous : [previous ?? initial] : Array.isArray(previous) ? previous[0] ?? initial : previous ?? initial });
30
+ };
31
+ return <div className="routing-condition" role="group" aria-label={`Condition ${index + 1}`}>
32
+ <label>Field<select aria-label={`Condition ${index + 1} field`} value={fields.some(([key]) => key === field) ? field : "custom"} disabled={disabled} onChange={event => {
33
+ const next = event.currentTarget.value;
34
+ const initial = next === "priority" ? 0 : next === "safeguard.requires_human" ? true : "";
35
+ onChange({ ...value, field: next === "custom" ? "answer.details" : next, ...(operator === "exists" ? {} : { value: list ? [initial] : initial }) });
36
+ }}>{fields.map(([key, label]) => <option value={key} key={key}>{label}</option>)}<option value="custom">Answer, trusted fact, or enrichment</option></select></label>
37
+ {!fields.some(([key]) => key === field) ? <label>Field name<input aria-label={`Condition ${index + 1} field name`} value={field} disabled={disabled} required onChange={event => onChange({ ...value, field: event.currentTarget.value })} /></label> : null}
38
+ <label>Comparison<select aria-label={`Condition ${index + 1} comparison`} value={operator} disabled={disabled} onChange={event => changeOperator(event.currentTarget.value)}>{operators.map(([key, label]) => <option key={key} value={key}>{label}</option>)}</select></label>
39
+ {operator === "exists" ? null : boolean ? <label>{list ? "Values" : "Value"}<select required aria-label={`Condition ${index + 1} value`} value={list ? [true, false].filter(item => Array.isArray(value.value) && value.value.includes(item)).join(",") : String(value.value)} disabled={disabled} onChange={event => onChange({ ...value, value: list ? event.currentTarget.value.split(",").map(item => item === "true") : event.currentTarget.value === "true" })}><option value="" disabled>Choose a value</option><option value="true">Yes</option><option value="false">No</option>{list ? <option value="true,false">Yes or no</option> : null}</select></label> : <label>{list ? "Values, separated by commas" : "Value"}<input aria-label={`Condition ${index + 1} value`} type={numeric && !list ? "number" : "text"} step="any" pattern={numeric && list ? "-?\\d+(?:\\.\\d+)?(?:\\s*,\\s*-?\\d+(?:\\.\\d+)?)*" : undefined} disabled={disabled} required value={Array.isArray(value.value) ? value.value.join(", ") : string(value.value)} onChange={event => {
40
+ const input = event.currentTarget.value;
41
+ onChange({ ...value, value: list ? input.split(",").map(item => item.trim()).map(item => numeric && item !== "" && Number.isFinite(Number(item)) ? Number(item) : item) : numeric && input !== "" ? Number(input) : input });
42
+ }} /></label>}
43
+ <button type="button" className="secondary" disabled={disabled} aria-label={`Remove condition ${index + 1}`} onClick={onRemove}>Remove</button>
44
+ </div>;
45
+ }
46
+
47
+ export function RoutingBuilder({ value, onChange, queues, disabled }: BuilderProps & { queues: QueueSummary[] }) {
48
+ const rules = objects(value.rules).sort((a, b) => Number(b.priority) - Number(a.priority) || string(a.id).localeCompare(string(b.id)));
49
+ const update = (index: number, next: JsonObject) => onChange({ ...value, rules: rules.map((rule, i) => i === index ? next : rule) });
50
+ const reorder = (index: number, destination: number) => {
51
+ const next = [...rules];
52
+ next.splice(destination, 0, next.splice(index, 1)[0]!);
53
+ onChange({ ...value, rules: next.map((rule, i) => ({ ...rule, priority: 100_000 - i })) });
54
+ };
55
+ return <div className="configuration-builder">
56
+ <section className="configuration-section"><h3>Where should cases go?</h3><p>Choose who handles new cases and who handles cases that need a safety review.</p>
57
+ <QueueChoice label="Default queue" help="Receives cases that do not match a rule below." value={string(value.default_queue_id)} queues={queues} onChange={next => onChange({ ...value, default_queue_id: next })} disabled={disabled} />
58
+ <QueueChoice label="Safety review queue" help="Human reviewers handle cases flagged by safeguards, before any routing rule or AI." value={string(value.safeguard_queue_id)} queues={queues} humanOnly onChange={next => onChange({ ...value, safeguard_queue_id: next })} disabled={disabled} />
59
+ </section>
60
+ <section className="configuration-section"><div className="configuration-section-heading"><div><h3>Routing rules</h3><p>Checked from top to bottom. The first matching rule chooses the queue.</p></div><button className="secondary" type="button" disabled={disabled || rules.length >= 100} onClick={() => onChange({ ...value, rules: [...rules, { id: `rule-${crypto.randomUUID()}`, priority: 0, queue_id: value.default_queue_id || "", match: "all", conditions: [{ field: "reason_code", operator: "eq", value: "" }] }].map((rule, i) => ({ ...rule, priority: 100_000 - i })) })}>Add rule</button></div>
61
+ {!rules.length ? <p className="configuration-empty">All cases go to the default queue, unless a safeguard requires human review.</p> : rules.map((rule, index) => <fieldset className="routing-rule" key={string(rule.id)}><legend>Rule {index + 1}</legend>
62
+ <div className="configuration-rule-actions"><button type="button" className="secondary" aria-label={`Move rule ${index + 1} up`} disabled={disabled || index === 0} onClick={() => reorder(index, index - 1)}>Move up</button><button type="button" className="secondary" aria-label={`Move rule ${index + 1} down`} disabled={disabled || index === rules.length - 1} onClick={() => reorder(index, index + 1)}>Move down</button><button type="button" className="secondary" disabled={disabled} aria-label={`Remove rule ${index + 1}`} onClick={() => onChange({ ...value, rules: rules.filter((_, i) => i !== index) })}>Remove rule</button></div>
63
+ <label>Match<select aria-label={`Rule ${index + 1} match`} value={string(rule.match)} disabled={disabled} onChange={event => update(index, { ...rule, match: event.currentTarget.value })}><option value="all">All of these conditions</option><option value="any">Any of these conditions</option></select></label>
64
+ {objects(rule.conditions).map((condition, i) => <Condition key={i} index={i} value={condition} disabled={disabled} onChange={next => update(index, { ...rule, conditions: objects(rule.conditions).map((item, j) => i === j ? next : item) })} onRemove={() => update(index, { ...rule, conditions: objects(rule.conditions).filter((_, j) => i !== j) })} />)}
65
+ {!objects(rule.conditions).length ? <p>This rule matches {rule.match === "all" ? "every case" : "no cases"}.</p> : null}
66
+ <button className="secondary" type="button" disabled={disabled || objects(rule.conditions).length >= 50} onClick={() => update(index, { ...rule, conditions: [...objects(rule.conditions), { field: "reason_code", operator: "eq", value: "" }] })}>Add condition</button>
67
+ <QueueChoice label={`Rule ${index + 1} queue`} help="Send matching cases to this queue." queues={queues} value={string(rule.queue_id)} disabled={disabled} onChange={next => update(index, { ...rule, queue_id: next })} />
68
+ </fieldset>)}
69
+ </section>
70
+ {objects(value.fixtures).length || objects(value.intake_enrichments).length ? <p className="configuration-retained">{objects(value.fixtures).length} saved routing examples and {objects(value.intake_enrichments).length} intake enrichments are retained. Use Advanced configuration to inspect them.</p> : null}
71
+ </div>;
72
+ }
73
+
74
+ export function PolicyBuilder({ value, onChange, disabled }: BuilderProps) {
75
+ const rules = objects(value.rules);
76
+ return <div className="configuration-builder"><label>Policy title<input required minLength={3} maxLength={160} disabled={disabled} value={string(value.title)} onChange={event => onChange({ ...value, title: event.currentTarget.value })} /></label>
77
+ <section className="configuration-section"><h3>Decision rules</h3><p>Give reviewers clear rules to apply when deciding a case.</p>
78
+ {rules.map((rule, index) => <fieldset key={index}><legend>Rule {index + 1}</legend><label>Rule title<input aria-label={`Rule ${index + 1} title`} required minLength={3} maxLength={200} disabled={disabled} value={string(rule.title)} onChange={event => onChange({ ...value, rules: rules.map((item, i) => i === index ? { ...item, title: event.currentTarget.value } : item) })} /></label>
79
+ <label>Guidance for reviewers<textarea aria-label={`Rule ${index + 1} guidance`} rows={3} disabled={disabled} value={string(rule.description)} onChange={event => onChange({ ...value, rules: rules.map((item, i) => i === index ? { ...item, description: event.currentTarget.value } : item) })} /></label>
80
+ <button className="secondary" type="button" disabled={disabled || rules.length === 1} onClick={() => onChange({ ...value, rules: rules.filter((_, i) => i !== index) })}>Remove rule {index + 1}</button></fieldset>)}
81
+ <button className="secondary" type="button" disabled={disabled || rules.length >= 500} onClick={() => onChange({ ...value, rules: [...rules, { code: `rule-${crypto.randomUUID()}`, title: "" }] })}>Add rule</button>
82
+ </section></div>;
83
+ }
84
+
85
+ export function MessageBuilder({ value, onChange, disabled }: BuilderProps) {
86
+ const variables = value.variables_schema && typeof value.variables_schema === "object" && !Array.isArray(value.variables_schema) ? value.variables_schema : {};
87
+ const setBody = (body: string) => {
88
+ const next = { ...variables };
89
+ for (const match of body.matchAll(/\{\{\s*([A-Za-z][A-Za-z0-9_]*)\s*\}\}/gu)) if (!(match[1]! in next)) next[match[1]!] = { type: "string" };
90
+ onChange({ ...value, body_template: body, variables_schema: next });
91
+ };
92
+ return <div className="configuration-builder"><div className="form-grid">
93
+ <label>Send to<select disabled={disabled} value={string(value.audience)} onChange={event => onChange({ ...value, audience: event.currentTarget.value })}><option value="reporter">Person who reported</option><option value="affected_user">Person affected by the decision</option></select></label>
94
+ <label>When to use<select disabled={disabled} value={string(value.purpose)} onChange={event => onChange({ ...value, purpose: event.currentTarget.value })}><option value="follow_up">Request more information</option><option value="outcome">Share the case outcome</option><option value="affected_user_notice">Explain an action taken</option><option value="appeal_outcome">Share the appeal outcome</option></select></label>
95
+ </div><label>Language<small>Language code, such as en, fr, or en-GB.</small><input required pattern="[a-z]{2,3}(?:-[A-Z]{2})?" maxLength={6} disabled={disabled} value={string(value.locale)} onChange={event => onChange({ ...value, locale: event.currentTarget.value })} /></label>
96
+ <label>Message<textarea required rows={6} maxLength={10000} disabled={disabled} value={string(value.body_template)} onChange={event => setBody(event.currentTarget.value)} /></label>
97
+ <div className="message-variable-buttons"><span>Insert a detail:</span>{[...new Set(["report_reference", ...Object.keys(variables)])].map(name => <button className="secondary" type="button" disabled={disabled} key={name} onClick={() => setBody(`${string(value.body_template)} {{${name}}}`)}>{name.replaceAll("_", " ")}</button>)}</div>
98
+ <label className="checkbox"><input type="checkbox" disabled={disabled} checked={value.automation_allowed === true} onChange={event => onChange({ ...value, automation_allowed: event.currentTarget.checked })} /><span>Allow this template in automated messages</span></label>
99
+ <section className="message-preview" aria-label="Message preview"><h3>Preview</h3><p>{string(value.body_template).replace(/\{\{\s*([A-Za-z][A-Za-z0-9_]*)\s*\}\}/gu, (_, name: string) => name === "report_reference" ? "RPT-EXAMPLE" : `[${name.replaceAll("_", " ")}]`) || "Your message will appear here."}</p><small>Example details are replaced with the recipient’s case details when sent.</small></section>
100
+ </div>;
101
+ }