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
@@ -0,0 +1,148 @@
1
+ import { useEditingAccess } from "./use-editing-access";
2
+ import { readPrivateDraft, savePrivateDraft, removePrivateDraft } from "./private-drafts";
3
+ import { useEffect, useId, useRef, useState, useSyncExternalStore, type FormEvent } from "react";
4
+ import { dateTime, readable } from "../lib/format";
5
+ import { ApiRequestError, errorMessage, mutationHeaders, requestJson } from "../lib/http";
6
+ import { getShellSnapshot, subscribeToShell } from "../shell/store";
7
+ import { dispatchOpenReport, reportEvents } from "./events";
8
+ import type { ReportDetailRecord, ReportDestination } from "./types";
9
+
10
+ type Task = {
11
+ id: string; runId: string; state: string; taskType: string; assignedOperatorId: string | null;
12
+ claimExpiresAt: string | null; dueAt: string | null; actionProposalId: string | null;
13
+ context: Record<string, unknown>;
14
+ responseSchema: { properties?: { decision_code?: { enum?: string[] } } };
15
+ };
16
+
17
+ function ReviewTask({ task, report, actorId, canApprove, canDecide, onChanged }: {
18
+ task: Task; report: ReportDetailRecord; actorId: string; canApprove: boolean; canDecide: boolean; onChanged: (message: string) => void;
19
+ }) {
20
+ const fieldId = useId();
21
+ const approval = Boolean(task.actionProposalId);
22
+ const options = task.responseSchema?.properties?.decision_code?.enum ?? [];
23
+ const [savedDraft] = useState(() => readPrivateDraft(actorId, `task:${task.id}`));
24
+ const [decision, setDecision] = useState(savedDraft?.taskDecision || "");
25
+ const [rationale, setRationale] = useState(savedDraft?.rationale || "");
26
+ const [evidence, setEvidence] = useState<string[]>(savedDraft?.evidence || []);
27
+ const [findings, setFindings] = useState<string[]>(savedDraft?.findings || []);
28
+ const [pending, setPending] = useState(false);
29
+ const [error, setError] = useState("");
30
+ const activeRequest = useRef(false);
31
+ const savedRequest = useRef<{ fingerprint: string; key: string } | null>(null);
32
+ const dirty = Boolean(decision || rationale || evidence.length || findings.length);
33
+ const activeAccess = useEditingAccess(task.claimExpiresAt);
34
+ const mine = task.state === "claimed" && task.assignedOperatorId === actorId && activeAccess;
35
+ const claimable = task.state === "open" || (task.state === "claimed" && !activeAccess);
36
+ const canSubmit = approval ? canApprove : canDecide;
37
+ const title = approval ? "Approve the customer action" : typeof task.context.title === "string" ? task.context.title : "Review the case";
38
+ const instructions = typeof task.context.instructions === "string" ? task.context.instructions : report.reviewerInstructions;
39
+ const toggle = (items: string[], value: string, checked: boolean) => checked ? [...new Set([...items, value])] : items.filter(item => item !== value);
40
+
41
+ useEffect(() => {
42
+ savePrivateDraft(actorId,`task:${task.id}`, { message:"",awaitReporter:false,decisionCode:"",policyCode:"",rationale,reporterNotice:"",affectedNotice:"",actionCode:"",evidence,taskDecision:decision,findings });
43
+ }, [actorId,task.id,rationale,evidence,decision,findings]);
44
+ useEffect(() => {
45
+ const leaving = (event: Event) => { if (activeRequest.current) event.preventDefault(); };
46
+ window.addEventListener(reportEvents.beforeClose, leaving);
47
+ return () => window.removeEventListener(reportEvents.beforeClose, leaving);
48
+ }, []);
49
+
50
+ const claim = async (release = false) => {
51
+ if (activeRequest.current) return;
52
+ activeRequest.current = true; setPending(true); setError("");
53
+ try {
54
+ await requestJson(`/v1/admin/tasks/${encodeURIComponent(task.id)}/claim`, { method: release ? "DELETE" : "POST", headers: mutationHeaders(false) });
55
+ onChanged(release ? "Task editing access released. Your private draft is saved in this tab." : "Task editing access is active. Choose an outcome and explain your reasoning.");
56
+ } catch (cause) { setError(errorMessage(cause, "The review claim could not be changed.")); }
57
+ finally { activeRequest.current = false; setPending(false); }
58
+ };
59
+ const submit = async (event: FormEvent) => {
60
+ event.preventDefault();
61
+ if (!mine || activeRequest.current || !decision || !rationale.trim()) return;
62
+ const response = approval ? { decision, reason: rationale.trim() } : { decision_code: decision, rationale: rationale.trim(), evidence_references: evidence, finding_ids: findings };
63
+ const fingerprint = JSON.stringify({ taskId: task.id, response });
64
+ if (savedRequest.current?.fingerprint !== fingerprint) savedRequest.current = { fingerprint, key: crypto.randomUUID() };
65
+ activeRequest.current = true; setPending(true); setError("");
66
+ try {
67
+ await requestJson(`/v1/admin/tasks/${encodeURIComponent(task.id)}/respond`, { method: "POST", headers: { "content-type": "application/json", "idempotency-key": savedRequest.current.key }, body: JSON.stringify({ response }) });
68
+ removePrivateDraft(actorId,`task:${task.id}`); setDecision(""); setRationale(""); setEvidence([]); setFindings([]); savedRequest.current = null;
69
+ onChanged(approval ? "Approval recorded. The playbook will verify the customer action before completing the outcome." : "Review submitted. The playbook is continuing from your saved response.");
70
+ } catch (cause) { setError(cause instanceof ApiRequestError ? cause.message : "The connection was interrupted. Your response is still here. Retry to check the same submission."); }
71
+ finally { activeRequest.current = false; setPending(false); }
72
+ };
73
+
74
+ return <article className="case-review-task" data-task-id={task.id} tabIndex={-1} aria-label={title}>
75
+ <div className="case-review-heading"><div><h4>{title}</h4>{task.dueAt ? <small>Due {dateTime(task.dueAt)}</small> : null}</div><span className="badge">{mine ? "Your review" : readable(task.state)}</span></div>
76
+ {instructions ? <p className="case-review-instructions">{instructions}</p> : null}
77
+ {approval ? <div className="case-action-preview"><strong>{readable(String(task.context.action_code || "Customer action"))}</strong><p>Review the exact target and payload before approving.</p><dl><dt>Target</dt><dd><pre tabIndex={0} aria-label="Proposed action target">{JSON.stringify(task.context.target, null, 2)}</pre></dd><dt>Action details</dt><dd><pre tabIndex={0} aria-label="Proposed action details">{JSON.stringify(task.context.payload, null, 2)}</pre></dd></dl></div> : null}
78
+ {mine ? <p className="form-help">Editing access lasts until {dateTime(task.claimExpiresAt || "")}. Your case ownership stays unchanged. <button type="button" className="text-button" disabled={pending} onClick={() => void claim(true)}>Release task access</button></p> : claimable ? <button className="primary" type="button" disabled={pending || !canSubmit} onClick={() => void claim()}>{pending ? "Starting…" : "Start task"}</button> : <p className="form-help">Another reviewer holds this task. You can continue reading the case.</p>}
79
+ {approval || options.length ? <form onSubmit={event => void submit(event)}><fieldset disabled={!mine || pending || !canSubmit}><legend>{approval ? "Your approval" : "Your review"}</legend>
80
+ <div className="case-review-field"><label htmlFor={`${fieldId}-outcome`}>{approval ? "Action approval" : "Review outcome"}</label><select id={`${fieldId}-outcome`} required value={decision} onChange={event => setDecision(event.currentTarget.value)}><option value="">Choose an outcome</option>{(approval ? ["approved", "rejected"] : options).map(code => <option value={code} key={code}>{readable(code)}</option>)}</select></div>
81
+ <div className="case-review-field"><label htmlFor={`${fieldId}-rationale`}>{approval ? "Approval reason" : "Review rationale"}</label><textarea id={`${fieldId}-rationale`} required maxLength={approval ? 1000 : 2000} value={rationale} onChange={event => setRationale(event.currentTarget.value)} placeholder="Explain what the evidence establishes and how the policy applies." /></div>
82
+ {!approval ? <div className="case-review-sources"><strong>Sources supporting this decision</strong><p className="form-help">Select only the evidence you considered. Missing information should remain explicit in your rationale.</p>{(report.evidence ?? []).map(item => <label className="checkbox" key={item.reference}><input type="checkbox" checked={evidence.includes(item.reference)} onChange={event => setEvidence(toggle(evidence, item.reference, event.currentTarget.checked))} /><span>{item.reference} · {readable(item.availability)}</span></label>)}{(report.findings ?? []).filter(item => item.id && item.runId === task.runId && item.status === "available").map(item => <label className="checkbox" key={item.id}><input type="checkbox" checked={findings.includes(item.id!)} onChange={event => setFindings(toggle(findings, item.id!, event.currentTarget.checked))} /><span>{item.summary} · {item.componentKey || "Case finding"}</span></label>)}{!report.evidence?.length && !report.findings?.length ? <p>No evidence sources have been recorded yet.</p> : null}</div> : null}
83
+ <button className="primary" type="submit">{pending ? "Submitting…" : approval ? "Submit action approval" : "Submit review and continue"}</button>
84
+ </fieldset></form> : <p className="error">This task uses a response form that is not supported in the case workspace yet. Ask an administrator to inspect the published task.</p>}
85
+ {!canSubmit ? <p className="form-help">{approval ? "Your role can read this task but cannot approve customer actions." : "Your role can read this task but cannot submit a case decision."}</p> : null}
86
+ {dirty ? <p className="private-draft-status">Private task draft saved in this tab.</p> : null}
87
+ {error ? <p className="error" role="alert">{error}</p> : null}
88
+ </article>;
89
+ }
90
+
91
+ export function CaseReviewTasks({ report, onOutcome, destination }: { report: ReportDetailRecord; onOutcome: () => void; destination?: ReportDestination }) {
92
+ const shell = useSyncExternalStore(subscribeToShell, getShellSnapshot, getShellSnapshot);
93
+ const [tasks, setTasks] = useState<Task[]>([]);
94
+ const [loading, setLoading] = useState(true);
95
+ const [error, setError] = useState("");
96
+ const [notice, setNotice] = useState("");
97
+ const [epoch, setEpoch] = useState(0);
98
+ const sectionRef = useRef<HTMLElement>(null);
99
+ const focusedRequest = useRef(0);
100
+ useEffect(() => {
101
+ if (loading || destination?.section !== "review" || !destination.taskId || focusedRequest.current === destination.request) return;
102
+ const frame = requestAnimationFrame(() => {
103
+ const target = [...(sectionRef.current?.querySelectorAll<HTMLElement>("[data-task-id]") || [])]
104
+ .find(element => element.dataset.taskId === destination.taskId);
105
+ if (!target || !target.getClientRects().length) return;
106
+ focusedRequest.current = destination.request;
107
+ target.focus({ preventScroll: true });
108
+ const content = target.closest<HTMLElement>(".report-detail-content");
109
+ if (content) {
110
+ const tabs = content.querySelector(".case-section-tabs")?.getBoundingClientRect().height || 0;
111
+ content.scrollTop += target.getBoundingClientRect().top - content.getBoundingClientRect().top - tabs - 12;
112
+ }
113
+ });
114
+ return () => cancelAnimationFrame(frame);
115
+ }, [destination, loading, tasks]);
116
+ const canReview = shell.session?.permissions.reviewTasks === true;
117
+ useEffect(() => {
118
+ if (!canReview) { setLoading(false); return; }
119
+ let active = true; let inFlight = false;
120
+ const refresh = async () => {
121
+ if (inFlight || document.visibilityState === "hidden") return;
122
+ inFlight = true;
123
+ try {
124
+ const result = await requestJson<{ tasks: Task[] }>(`/v1/admin/tasks?report_id=${encodeURIComponent(report.id)}&limit=100`);
125
+ if (active) { setTasks(result.tasks); setError(""); if (!result.tasks.some(task => ["open", "claimed"].includes(task.state))) dispatchOpenReport(report.id); }
126
+ } catch (cause) { if (active) setError(errorMessage(cause, "Review tasks could not be loaded.")); }
127
+ finally { inFlight = false; if (active) setLoading(false); }
128
+ };
129
+ void refresh(); const timer = setInterval(() => void refresh(), 5000);
130
+ return () => { active = false; clearInterval(timer); };
131
+ }, [report.id, canReview, epoch]);
132
+ const changed = (message: string) => { setNotice(message); setEpoch(value => value + 1); dispatchOpenReport(report.id); };
133
+ const open = tasks.filter(task => ["open", "claimed"].includes(task.state));
134
+ const actions = (report.workflowActions || []).filter(action => action.recordKind === "proposal");
135
+ const unresolved = actions.some(action => action.state !== "applied");
136
+ const failed = actions.some(action => action.state === "failed" && action.terminal);
137
+ const waitingNotices = (report.notices || []).some(notice => notice.decisionId && notice.deliveryState !== "delivered");
138
+ const terminal = report.workflowRuns?.some(run => run.runKind === "primary" && ["succeeded","failed","terminated","repair_required"].includes(run.state));
139
+ const completed = ["resolved_by_human","resolved_by_ai","closed","appealed"].includes(report.state);
140
+ return <section ref={sectionRef} className="case-next-step" aria-label="Playbook next step"><div className="case-review-heading"><div><h3>Next step</h3><p>This case follows its published playbook. Complete the current task to continue.</p></div><button className="secondary" type="button" onClick={() => { setEpoch(value => value + 1); dispatchOpenReport(report.id); }}>Refresh progress</button></div>
141
+ {!canReview ? <p>Your role can read this case. A reviewer with access to its queue must complete the next task.</p> : loading ? <p role="status">Loading the current review…</p> : open.length ? open.map(task => <ReviewTask key={task.id} task={task} report={report} actorId={shell.session!.actor.id} canApprove={shell.session?.permissions.approveActions === true} canDecide={shell.session?.permissions.decideReports === true} onChanged={changed} />) : completed ? <p>The review is recorded. <button type="button" className="text-button" onClick={onOutcome}>Inspect the outcome</button></p>
142
+ : unresolved ? <p>{failed ? "A customer action needs recovery. The case remains open." : "The playbook is waiting for customer action confirmation."} <button type="button" className="text-button" onClick={onOutcome}>Review customer actions</button></p>
143
+ : waitingNotices ? <p>Participant notice delivery is still outstanding. <button type="button" className="text-button" onClick={onOutcome}>Review participant notices</button></p>
144
+ : terminal ? <p>The playbook stopped without a completed outcome. Review its outcome and delivery history before continuing. <button type="button" className="text-button" onClick={onOutcome}>Inspect unresolved work</button></p>
145
+ : <p>The playbook is continuing. Its next review or approval will appear here.</p>}
146
+ {notice ? <p role="status">{notice}</p> : null}{error ? <p className="error" role="alert">{error} Use Refresh progress to try again.</p> : null}
147
+ </section>;
148
+ }
@@ -0,0 +1,29 @@
1
+ import { readable } from "../lib/format";
2
+ import type { FindingRecord } from "./types";
3
+ const strings=(value:unknown):string[]=>Array.isArray(value)?value.filter((item):item is string=>typeof item==="string"):[];
4
+ const object=(value:unknown):Record<string,unknown>=>value && typeof value==="object" && !Array.isArray(value)?value as Record<string,unknown>:{};
5
+ function gapLabel(value:string) {
6
+ if(value.includes("budget"))return "The configured investigation budget was reached.";
7
+ if(value.includes("step_limit") || value.includes("proposal_missing"))return "The investigation reached its limit without a complete proposal.";
8
+ if(value.includes("time_limit"))return "The investigation timed out before it could complete.";
9
+ if(value.includes("tool_denied"))return "A requested tool was outside this playbook’s permitted scope.";
10
+ if(value.startsWith("investigation_") || value.startsWith("agent_"))return "The investigation could not validate a complete proposal. Review the available evidence directly.";
11
+ return value;
12
+ }
13
+ export function InvestigationBrief({findings,onEvidence}:{findings:FindingRecord[];onEvidence?:()=>void}) {
14
+ const investigation=[...findings].reverse().find(finding=>finding.findingType==="case-investigation");
15
+ if(!investigation)return null;
16
+ const data=object(investigation.data),execution=object(data.investigation);
17
+ const gaps=strings(data.missing_information),uncertainty=strings(data.uncertainty),citations=strings(data.citations);
18
+ const sources=Array.isArray(execution.sources)?execution.sources.map(object):[];
19
+ return <section className="investigation-brief" aria-label="Investigation brief">
20
+ <div className="investigation-brief-heading"><h3>Investigation brief</h3><span className="badge">{data.result_code==="needs_information"?"Needs more information":"Ready for review"}</span></div>
21
+ <p>{typeof data.summary==="string"?data.summary:investigation.summary}</p>
22
+ <p className="form-help">This is a proposal. Check the cited sources and apply your policy before deciding.</p>
23
+ {execution.fixture===true?<p className="investigation-fixture">Local test model — this result validates workflow behavior only.</p>:null}
24
+ {gaps.length?<div><h4>Missing information</h4><ul>{gaps.map((gap,index)=><li key={index}>{gapLabel(gap)}</li>)}</ul></div>:null}
25
+ {uncertainty.length?<div><h4>Uncertainty</h4><ul>{uncertainty.map((item,index)=><li key={index}>{gapLabel(item)}</li>)}</ul></div>:null}
26
+ <div><h4>Sources considered</h4>{citations.length?<ul>{citations.map(id=>{const finding=findings.find(f=>f.id===id);const source=sources.find(s=>s.id===id);return <li key={id}>{finding?.summary || (typeof source?.label==="string"?readable(source.label):"Case intake")}<small>{id}</small></li>;})}</ul>:<p>No source was validated for this proposal.</p>}{onEvidence && citations.length?<button className="secondary" type="button" onClick={onEvidence}>Review cited evidence</button>:null}</div>
27
+ <details><summary>Investigation activity</summary><p>{Number(execution.steps)||0} model steps · {Number(execution.tool_calls)||0} tool calls</p>{Array.isArray(execution.trace)?<ol>{execution.trace.map(object).map((entry,index)=><li key={index}>{readable(String(entry.tool))} · {readable(String(entry.status))}</li>)}</ol>:null}<p>{typeof execution.provider==="string"?readable(execution.provider):"Configured provider"} · {String(execution.model||"Configured model")}</p></details>
28
+ </section>;
29
+ }
@@ -1,17 +1,25 @@
1
- import { useEffect, useMemo, useState, useSyncExternalStore, type FormEvent } from "react";
1
+ import { useEditingAccess } from "./use-editing-access";
2
+ import { CaseOwnership } from "./CaseOwnership";
3
+ import { readPrivateDraft, savePrivateDraft } from "./private-drafts";
4
+ import { CaseNotices } from "./CaseNotices";
5
+ import { CaseAppeals, RelatedReports } from "./CaseContinuity";
6
+ import { useEffect, useMemo, useRef, useState, useSyncExternalStore, type FormEvent } from "react";
2
7
  import { AnalystAvatar } from "../components/AnalystIdentity";
3
8
  import { dateTime, readable, relativeTime, safeCustomerUrl } from "../lib/format";
9
+ import { CaseActions } from "./CaseActions";
10
+ import { CaseReviewTasks } from "./CaseReviewTasks";
11
+ import { InvestigationBrief } from "./InvestigationBrief";
4
12
  import { EnrichmentFindings } from "./EnrichmentFindings";
5
13
  import {
6
- dispatchClaimReport,
14
+ reportEvents,
15
+ dispatchOpenReport,
7
16
  dispatchCloseReport,
8
17
  dispatchHumanDecision,
9
- dispatchReleaseReport,
10
18
  dispatchReporterMessage,
11
19
  } from "./events";
12
20
  import { otherReviewersOnReport, reportPresenceLabel } from "./presence-presentation";
13
21
  import { getReportWorkspaceSnapshot, subscribeToReportWorkspace } from "./store";
14
- import type { PresenceAnalyst, ReportAnswer, ReportDetailRecord, TimelineEvent } from "./types";
22
+ import type { PresenceAnalyst, ReportAnswer, ReportDetailRecord, ReportDestination, TimelineEvent } from "./types";
15
23
 
16
24
  function Fact({ label, children }: { label: string; children: React.ReactNode }) {
17
25
  return <><dt>{label}</dt><dd>{children || "—"}</dd></>;
@@ -114,23 +122,24 @@ const decisionOptions = [
114
122
  { value: "duplicate", label: "Duplicate report" },
115
123
  ] as const;
116
124
 
117
- function DetailContent({ report, actorId, generatedAt, actionCodes, claimReports, messageReporters, decideReports, readEvidence, pendingAction, messageMutationVersion, statusMessage, error }: {
125
+ function DetailContent({ report, actorId, actionCodes, messageReporters, decideReports, readEvidence, pendingAction, messageMutationVersion, reviewRequest, destination }: {
126
+ reviewRequest: number;
127
+ destination?: ReportDestination;
118
128
  report: ReportDetailRecord;
119
129
  actorId: string;
120
- generatedAt: string;
121
130
  actionCodes: string[];
122
- claimReports: boolean;
123
131
  messageReporters: boolean;
124
132
  decideReports: boolean;
125
133
  readEvidence: boolean;
126
- pendingAction: "claim" | "release" | "message" | "decision" | null;
134
+ pendingAction: "claim" | "release" | "transfer" | "message" | "decision" | null;
127
135
  messageMutationVersion: number;
128
- statusMessage: string;
129
- error: string;
130
136
  }) {
131
137
  const evidence = report.evidence ?? [];
132
138
  const answers = report.answers ?? [];
133
139
  const messages = report.messages ?? [];
140
+ const [audience, setAudience] = useState("reporter");
141
+ const conversation = messages.filter(item => (item.audience || "reporter") === audience);
142
+ const customerFacts = Object.entries(report.customerFacts || {});
134
143
  const workflowRuns = report.workflowRuns ?? [];
135
144
  const timeline = report.timeline ?? [];
136
145
  const caseTimeline = timeline.filter((item) => {
@@ -145,41 +154,55 @@ function DetailContent({ report, actorId, generatedAt, actionCodes, claimReports
145
154
  const decisions = report.decisions ?? [];
146
155
  const aiRuns = report.aiRuns ?? [];
147
156
  const rules = report.policyRules ?? [];
148
- const [selectedEvidence, setSelectedEvidence] = useState<Set<string>>(() => new Set(evidence.map((item) => item.reference)));
149
- const [message, setMessage] = useState("");
150
- const [awaitReporter, setAwaitReporter] = useState(true);
151
- const [decisionCode, setDecisionCode] = useState("no_violation_found");
152
- const [policyCode, setPolicyCode] = useState(rules[0]?.code ?? "");
153
- const [rationale, setRationale] = useState("");
154
- const [reporterNotice, setReporterNotice] = useState("");
155
- const [affectedNotice, setAffectedNotice] = useState("");
156
- const [actionCode, setActionCode] = useState("");
157
+ const [caseTab, setCaseTab] = useState("overview");
158
+ const tabsRef = useRef<HTMLDivElement>(null);
159
+ const selectTab = (tab: string) => {
160
+ setCaseTab(tab);
161
+ requestAnimationFrame(() => tabsRef.current?.closest(".report-detail-content")?.scrollTo({ top: 0, behavior: "auto" }));
162
+ };
163
+ const [savedDraft] = useState(() => readPrivateDraft(actorId, report.id));
164
+ const [selectedEvidence, setSelectedEvidence] = useState<Set<string>>(() => new Set(savedDraft?.evidence ?? evidence.map((item) => item.reference)));
165
+ const [message, setMessage] = useState(savedDraft?.message ?? (""));
166
+ const [awaitReporter, setAwaitReporter] = useState(savedDraft?.awaitReporter ?? (true));
167
+ const [decisionCode, setDecisionCode] = useState(savedDraft?.decisionCode ?? ("no_violation_found"));
168
+ const [policyCode, setPolicyCode] = useState(savedDraft?.policyCode ?? (rules[0]?.code ?? ""));
169
+ const [rationale, setRationale] = useState(savedDraft?.rationale ?? (""));
170
+ const [reporterNotice, setReporterNotice] = useState(savedDraft?.reporterNotice ?? (""));
171
+ const [affectedNotice, setAffectedNotice] = useState(savedDraft?.affectedNotice ?? (""));
172
+ const [actionCode, setActionCode] = useState(savedDraft?.actionCode ?? (""));
157
173
 
174
+ useEffect(() => { if (reviewRequest) selectTab("review"); }, [reviewRequest]);
175
+ useEffect(() => {
176
+ if (!destination) return;
177
+ selectTab(destination.section);
178
+ const frame = requestAnimationFrame(() => {
179
+ tabsRef.current?.querySelector<HTMLButtonElement>(`#case-tab-${destination.section}`)?.focus({ preventScroll: true });
180
+ });
181
+ return () => cancelAnimationFrame(frame);
182
+ }, [destination]);
183
+ const sentVersion = useRef(messageMutationVersion);
184
+ useEffect(() => {
185
+ if (sentVersion.current !== messageMutationVersion) { setMessage(""); sentVersion.current = messageMutationVersion; }
186
+ }, [messageMutationVersion]);
187
+ const hasDraft = Boolean(message || rationale || reporterNotice || affectedNotice || actionCode);
188
+ useEffect(() => {
189
+ savePrivateDraft(actorId, report.id, { message,awaitReporter,decisionCode,policyCode,rationale,reporterNotice,affectedNotice,actionCode,evidence:[...selectedEvidence] });
190
+ }, [actorId,report.id,message,awaitReporter,decisionCode,policyCode,rationale,reporterNotice,affectedNotice,actionCode,selectedEvidence]);
158
191
  useEffect(() => {
159
- setSelectedEvidence(new Set(evidence.map((item) => item.reference)));
160
- setMessage("");
161
- setAwaitReporter(true);
162
- setDecisionCode("no_violation_found");
163
- setPolicyCode(rules[0]?.code ?? "");
164
- setRationale("");
165
- setReporterNotice("");
166
- setAffectedNotice("");
167
- setActionCode("");
168
- }, [report.id]);
169
- useEffect(() => setMessage(""), [messageMutationVersion]);
192
+ const leaving = (event: Event) => { if (pendingAction) event.preventDefault(); };
193
+ window.addEventListener(reportEvents.beforeClose, leaving);
194
+ return () => window.removeEventListener(reportEvents.beforeClose, leaving);
195
+ }, [pendingAction]);
196
+
170
197
 
171
198
  const deliveries = useMemo(() => [
172
199
  ...(report.actions ?? []).map((action) => ({ id: `action-${action.id ?? action.actionCode}`, title: `Application action · ${readable(action.actionCode)}`, state: action.status, detail: `${action.attemptCount} attempt${action.attemptCount === 1 ? "" : "s"}${action.errorCode ? ` · ${readable(action.errorCode)}` : ""}` })),
173
- ...(report.notices ?? []).map((notice) => ({ id: `notice-${notice.id ?? notice.audience}`, title: `${readable(notice.audience)} notice`, state: notice.deliveryState, detail: `Updated ${relativeTime(notice.updatedAt)}` })),
174
- ...(report.workflowActions ?? []).filter((action) => action.recordKind === "proposal").map((action) => ({ id: `workflow-${action.id ?? action.actionCode}`, title: `Workflow action · ${readable(action.actionCode)}`, state: action.state, detail: `${readable(action.authorityMode || action.recordKind || "proposal")} · ${action.attemptCount || 0} attempt${action.attemptCount === 1 ? "" : "s"}${action.errorCode ? ` · ${readable(action.errorCode)}` : ""}` })),
175
200
  ], [report]);
176
201
  const customerUrl = safeCustomerUrl(report.customerUrl);
177
- const activeClaim = Boolean(report.claim && Date.parse(report.claim.expiresAt) > Date.parse(generatedAt));
202
+ const activeClaim = useEditingAccess(report.claim?.expiresAt);
178
203
  const mine = Boolean(activeClaim && report.claim?.reviewerId === actorId);
179
- const claimable = claimReports && ["human_review", "awaiting_reporter", "human_review_requested"].includes(report.state);
180
204
  const canMessage = mine && messageReporters;
181
205
  const canDecide = mine && decideReports && rules.length > 0;
182
- const changingClaim = pendingAction === "claim" || pendingAction === "release";
183
206
 
184
207
  const sendMessage = (event: FormEvent<HTMLFormElement>): void => {
185
208
  event.preventDefault();
@@ -190,10 +213,14 @@ function DetailContent({ report, actorId, generatedAt, actionCodes, claimReports
190
213
  dispatchHumanDecision({ decisionCode, policyCode, rationale, evidenceReferences: [...selectedEvidence], reporterNotice, affectedUserNotice: affectedNotice, actionCode });
191
214
  };
192
215
 
193
- return (
194
- <>
195
- <div className="detail-grid">
196
- <section className="report-submission"><h3>Reporter submission</h3><dl className="submission-meta">
216
+ const tabs = [["overview","Overview"],["evidence","Evidence"],["review","Review"],["messages","Messages"],["outcome","Outcome"],["activity","Activity"]];
217
+ const primaryRun = workflowRuns.find(run => run.runKind === "primary");
218
+ const missingFindings = findings.filter(finding => ["unavailable","invalid","stale"].includes(finding.status || "") || Boolean(finding.expiresAt && Date.parse(finding.expiresAt) < Date.now()));
219
+ const unconfirmedActions = (report.workflowActions || []).filter(action => action.recordKind === "proposal" && action.state !== "applied").length + (report.actions || []).filter(action => !["accepted","already_applied"].includes(action.status)).length;
220
+ const deliveryProblems = (report.notices || []).filter(notice => ["failed","suppressed"].includes(notice.deliveryState)).length;
221
+ const taskReview = report.playbookManaged || Boolean(destination?.taskId);
222
+ const panels: Record<string, React.ReactNode> = {
223
+ overview: <><div className="case-context-grid"><section className="report-submission"><h3>Reporter submission</h3><dl className="submission-meta">
197
224
  <Fact label="Form">{report.formTitle || report.formVersionId || "Unknown form"}</Fact>
198
225
  <Fact label="Channel">{readable(report.source || "unknown")}</Fact>
199
226
  <Fact label="Submitted">{dateTime(report.submittedAt || report.receivedAt)}</Fact>
@@ -209,49 +236,64 @@ function DetailContent({ report, actorId, generatedAt, actionCodes, claimReports
209
236
  <Fact label="Customer content">{customerUrl ? <a className="text-button" href={customerUrl} target="_blank" rel="noopener noreferrer" referrerPolicy="no-referrer">Open in customer application ↗</a> : "Not provided"}</Fact>
210
237
  <Fact label="Owner reference">{report.ownerReference || "—"}</Fact>
211
238
  <Fact label="Reporter reference">{report.reporterReference || "—"}</Fact>
212
- <Fact label="Adjudication policy">{report.policyTitle || "Policy"} · {report.policyVersionId || "unavailable"}</Fact>
213
- <Fact label="Queue policy version">{report.queuePolicyVersionId || "—"}</Fact>
239
+ <Fact label="Adjudication policy">{report.policyTitle || "Policy unavailable"}</Fact>
214
240
  <Fact label="Reviewer policy">{report.reviewerInstructions || "—"}</Fact>
215
241
  <Fact label="Received">{dateTime(report.receivedAt)}</Fact>
216
- </dl></section>
242
+ </dl></section></div><section className="case-guidance"><h3>Customer context</h3><p>Context supplied at intake is separate from the reporter’s account and retrieved evidence.</p>{customerFacts.length ? <dl className="case-customer-facts">{customerFacts.map(([key,value]) => <div key={key}><dt>{readable(key)}</dt><dd>{typeof value === "boolean" ? value ? "Yes" : "No" : typeof value === "object" ? JSON.stringify(value) : String(value ?? "Not provided")}</dd></div>)}</dl> : <p>No customer context is available in this view.</p>}</section><section className="case-guidance"><h3>What to do next</h3><p>{decisions.length ? unconfirmedActions ? "A decision is recorded. Customer action confirmation is still outstanding." : deliveryProblems ? "A decision is recorded. Check the participant deliveries that need attention." : "Review the recorded outcome and its delivery history." : missingFindings.length ? "Some context is missing or expired. Review the evidence gaps before deciding." : "Read the allegation and available evidence, then complete the playbook’s next step."}</p><button type="button" className="primary" onClick={() => selectTab(decisions.length ? "outcome" : "evidence")}>{decisions.length ? "Inspect outcome" : "Review evidence"}</button></section><RelatedReports report={report} /></>,
243
+ evidence: <><div className="detail-grid">
217
244
  <section><h3>Evidence references considered</h3><div className="evidence-list">
218
245
  {evidence.length ? evidence.map((item) => <label className="evidence-item checkbox" key={item.reference}><input type="checkbox" checked={selectedEvidence.has(item.reference)} onChange={(event) => setSelectedEvidence((current) => { const next = new Set(current); if (event.currentTarget.checked) next.add(item.reference); else next.delete(item.reference); return next; })} /><span>{item.reference} · {readable(item.kind)} · {readable(item.availability)}</span></label>) : <EmptyLine>No evidence references were recorded.</EmptyLine>}
219
- </div></section>
220
- </div>
221
-
222
- <EnrichmentFindings findings={findings} workflowRuns={workflowRuns} permitted={readEvidence} />
223
-
224
- {workflowRuns.length || timeline.length ? <section className="case-activity-section"><div className="case-activity-heading"><div><h3>Case activity</h3><p>A clear record of reporter communication, review, and decisions.</p></div><span>{caseTimeline.length} event{caseTimeline.length === 1 ? "" : "s"}</span></div><div className="report-timeline">{caseTimeline.map((item, index) => <article className="timeline-event" key={item.id ?? `${item.eventType}-${item.createdAt}-${index}`}><span className={`timeline-marker ${item.eventType || "event"}`} aria-hidden="true" /><div><strong>{timelineTitle(item)}</strong><span>{timelineDetail(item)}</span><small>{dateTime(item.createdAt)} · {relativeTime(item.createdAt)} · {readable(item.actorType || "system")}</small></div></article>)}</div>{workflowRuns.length || technicalTimeline.length ? <details className="workflow-technical"><summary>Technical workflow details <span>{workflowRuns.length} workflow run{workflowRuns.length === 1 ? "" : "s"}</span></summary><div className="report-run-list">{workflowRuns.map((run) => <article className="report-run-record" key={run.id}><div><strong>{run.workflowName || run.workflowKey || `Workflow ${String(run.workflowVersionId || run.id).slice(0, 12)}`}</strong><small>{readable(run.runKind || "primary")} · {readable(run.authorityMode || "assist")} · immutable version {run.workflowVersion || String(run.workflowVersionId || "").slice(0, 12)}</small></div><span className={`badge${["failed", "repair_required"].includes(run.state) ? " urgent" : ""}`}>{readable(run.state)}</span></article>)}</div>{technicalTimeline.length ? <p className="technical-event-count">{technicalTimeline.length} low-level execution event{technicalTimeline.length === 1 ? "" : "s"} recorded in the audit trail.</p> : null}</details> : null}</section> : null}
225
-
226
- <section><h3>Case messages</h3><div className="message-list">{messages.length ? messages.map((item, index) => <article className={`case-message ${item.direction}`} key={item.id ?? `${item.createdAt}-${index}`}><div>{item.body}</div><small>{readable(item.senderType)} · {dateTime(item.createdAt)} · {deliveryLabel(item.deliveryState)}{deliveryExplanation(item.deliveryState) ? ` — ${deliveryExplanation(item.deliveryState)}` : ""}</small></article>) : <EmptyLine>No case messages yet.</EmptyLine>}</div></section>
227
-
228
- {decisions.length ? <section><h3>Decisions &amp; rationale</h3><div className="decision-record-list">{decisions.map((decision, index) => <article className="decision-record" key={decision.id ?? `${decision.createdAt}-${index}`}><div><strong>{readable(decision.decisionCode)}</strong><span className="badge">{decision.policyCode || "Policy decision"}</span></div><p>{decision.rationale || "No rationale recorded."}</p><small>{readable(decision.makerType)} · {dateTime(decision.createdAt)} · {(decision.evidenceReferences || []).length} evidence reference(s)</small></article>)}</div></section> : null}
229
-
230
- {deliveries.length ? <section><h3>Decision and delivery</h3><div className="delivery-list">{deliveries.map((delivery) => <article key={delivery.id}><div><strong>{delivery.title}</strong><small>{delivery.detail}{deliveryExplanation(delivery.state) ? ` · ${deliveryExplanation(delivery.state)}` : ""}</small></div><span className={`badge${["failed", "rejected", "bounced", "suppressed"].includes(delivery.state) ? " urgent" : ""}`}>{deliveryLabel(delivery.state)}</span></article>)}</div></section> : null}
246
+ </div></section></div><EnrichmentFindings findings={findings} workflowRuns={workflowRuns} permitted={readEvidence} /><button className="primary case-continue" type="button" onClick={() => selectTab("review")}>Continue to review</button></>,
247
+ review: <>{readEvidence ? <InvestigationBrief findings={findings} onEvidence={() => selectTab("evidence")} /> : null}{taskReview ? <CaseReviewTasks key={report.id} report={report} destination={destination} onOutcome={() => selectTab("outcome")} /> : null}<div className="detail-actions case-review-form">{!taskReview && ["human_review", "awaiting_reporter", "human_review_requested"].includes(report.state) ? <form onSubmit={recordDecision}><h3>Record a human decision</h3><label>Decision outcome<select value={decisionCode} onChange={(event) => setDecisionCode(event.currentTarget.value)} disabled={!canDecide || Boolean(pendingAction)} required>{decisionOptions.map((option) => <option value={option.value} key={option.value}>{option.label}</option>)}</select></label><label>Policy code<select value={policyCode} onChange={(event) => setPolicyCode(event.currentTarget.value)} disabled={!canDecide || Boolean(pendingAction)} required>{rules.map((rule) => <option value={rule.code} key={rule.code}>{rule.title ? `${rule.code} · ${rule.title}` : rule.code}</option>)}</select></label><label>Internal rationale<textarea maxLength={8000} value={rationale} onChange={(event) => setRationale(event.currentTarget.value)} disabled={!canDecide || Boolean(pendingAction)} required /></label><label>Reporter outcome<textarea maxLength={4000} value={reporterNotice} onChange={(event) => setReporterNotice(event.currentTarget.value)} disabled={!canDecide || Boolean(pendingAction)} required /></label><label>Affected-user notice <small>Optional; sent through a separate audience channel.</small><textarea maxLength={4000} value={affectedNotice} onChange={(event) => setAffectedNotice(event.currentTarget.value)} disabled={!canDecide || Boolean(pendingAction)} /></label>{actionCodes.length ? <label>Application action <small>Optional. Only deployment-allowlisted actions are shown.</small><select value={actionCode} onChange={(event) => setActionCode(event.currentTarget.value)} disabled={!canDecide || Boolean(pendingAction)}><option value="">No application action</option>{actionCodes.map((code) => <option value={code} key={code}>{readable(code)}</option>)}</select></label> : null}<button className="primary" type="submit" disabled={!canDecide || Boolean(pendingAction)}>{pendingAction === "decision" ? "Recording…" : "Complete human review"}</button></form> : null}</div>{!taskReview && !["human_review","awaiting_reporter","human_review_requested"].includes(report.state) ? <section className="case-guidance"><h3>No decision is waiting here</h3><p>Check Outcome for the recorded decision and Activity for the case’s progress.</p></section> : null}</>,
248
+ messages: <><section><h3>Case messages</h3><p>Choose an audience to read that conversation.</p><div className="case-audience-picker" role="group" aria-label="Conversation audience">{[["reporter","Reporter"],["affected_user","Affected person"],["internal","Internal team"]].map(([key,label]) => <button className="secondary" type="button" aria-pressed={audience === key} key={key} onClick={() => setAudience(key!)}>{label}</button>)}</div><div className="message-list">{conversation.length ? conversation.map((item, index) => <article className={`case-message ${item.direction}`} key={item.id ?? `${item.createdAt}-${index}`}><strong className="message-audience">{item.audience === "affected_user" ? "Affected person" : item.audience === "internal" ? "Internal team" : "Reporter"}</strong><div>{item.body}</div><small>{readable(item.senderType)} · {dateTime(item.createdAt)} · {deliveryLabel(item.deliveryState)}{deliveryExplanation(item.deliveryState) ? ` — ${deliveryExplanation(item.deliveryState)}` : ""}</small></article>) : <EmptyLine>No messages in this conversation yet.</EmptyLine>}</div></section><div className="detail-actions case-message-form">{audience === "reporter" ? <form onSubmit={sendMessage}><h3>Ask the reporter</h3><p className="form-help">The update is emailed when the reporter supplied an address. Otherwise, Resolve uses the configured signed customer notification channel.</p><label>Message<textarea maxLength={4000} required value={message} onChange={(event) => setMessage(event.currentTarget.value)} disabled={!canMessage || Boolean(pendingAction)} /></label><label className="checkbox"><input type="checkbox" checked={awaitReporter} onChange={(event) => setAwaitReporter(event.currentTarget.checked)} disabled={!canMessage || Boolean(pendingAction)} />Move to awaiting reporter</label><button className="secondary" type="submit" disabled={!canMessage || Boolean(pendingAction)}>{pendingAction === "message" ? "Queueing…" : "Send participant update"}</button></form> : <p>Outcome notices for this audience appear here when they are recorded. Participant delivery follows the case’s configured channels.</p>}</div></>,
249
+ outcome: <>{decisions.length ? <section><h3>Decisions &amp; rationale</h3><div className="decision-record-list">{decisions.map((decision, index) => <article className="decision-record" key={decision.id ?? `${decision.createdAt}-${index}`}><div><strong>{readable(decision.decisionCode)}</strong><span className="badge">{decision.policyCode || "Policy decision"}</span></div><p>{decision.rationale || "No rationale recorded."}</p><small>{readable(decision.makerType)} · {dateTime(decision.createdAt)} · {(decision.evidenceReferences || []).length} evidence reference(s)</small></article>)}</div></section> : null}{!decisions.length ? <section className="case-guidance"><h3>No decision recorded</h3><p>Complete the required review before proposing a customer action or sending an outcome.</p><button type="button" className="secondary" onClick={() => selectTab("review")}>Open review</button></section> : null}{(report.workflowActions || []).some(action => action.recordKind === "proposal") ? <CaseActions report={report} onReview={() => selectTab("review")} /> : null}<CaseAppeals report={report} />{report.notices?.length ? <CaseNotices report={report} /> : null}{deliveries.length ? <section><h3>Other application deliveries</h3><div className="delivery-list">{deliveries.map((delivery) => <article key={delivery.id}><div><strong>{delivery.title}</strong><small>{delivery.detail}{deliveryExplanation(delivery.state) ? ` · ${deliveryExplanation(delivery.state)}` : ""}</small></div><span className={`badge${["failed", "rejected", "bounced", "suppressed"].includes(delivery.state) ? " urgent" : ""}`}>{deliveryLabel(delivery.state)}</span></article>)}</div></section> : null}{decisions.length && !deliveries.length && !report.notices?.length && !report.workflowActions?.length ? <p>No customer actions or participant notices have been recorded for this decision.</p> : null}</>,
250
+ activity: <>{report.handoffs?.length ? <section className="case-handoff-history"><h3>Team handoffs</h3>{report.handoffs.map(item => <article key={item.id}><strong>{item.toReviewerId ? "Case transferred" : "Returned to queue"}</strong><p>{item.note}</p><small>{item.authorName} · {dateTime(item.createdAt)}</small></article>)}</section> : null}{workflowRuns.length || timeline.length ? <section className="case-activity-section"><div className="case-activity-heading"><div><h3>Case activity</h3><p>A clear record of reporter communication, review, and decisions.</p></div><span>{caseTimeline.length} event{caseTimeline.length === 1 ? "" : "s"}</span></div><div className="report-timeline">{caseTimeline.map((item, index) => <article className="timeline-event" key={item.id ?? `${item.eventType}-${item.createdAt}-${index}`}><span className={`timeline-marker ${item.eventType || "event"}`} aria-hidden="true" /><div><strong>{timelineTitle(item)}</strong><span>{timelineDetail(item)}</span><small>{dateTime(item.createdAt)} · {relativeTime(item.createdAt)} · {readable(item.actorType || "system")}</small></div></article>)}</div>{workflowRuns.length || technicalTimeline.length ? <details className="workflow-technical"><summary>Technical workflow details <span>{workflowRuns.length} workflow run{workflowRuns.length === 1 ? "" : "s"}</span></summary><div className="report-run-list">{workflowRuns.map((run) => <article className="report-run-record" key={run.id}><div><strong>{run.workflowName || run.workflowKey || `Workflow ${String(run.workflowVersionId || run.id).slice(0, 12)}`}</strong><small>{readable(run.runKind || "primary")} · {readable(run.authorityMode || "assist")} · immutable version {run.workflowVersion || String(run.workflowVersionId || "").slice(0, 12)}</small></div><span className={`badge${["failed", "repair_required"].includes(run.state) ? " urgent" : ""}`}>{readable(run.state)}</span></article>)}</div>{technicalTimeline.length ? <p className="technical-event-count">{technicalTimeline.length} low-level execution event{technicalTimeline.length === 1 ? "" : "s"} recorded in the audit trail.</p> : null}</details> : null}</section> : null} {aiRuns.length ? <section><h3>Bounded AI record</h3><div className="ai-run-list">{aiRuns.map((run, index) => <article key={run.id ?? `${run.model}-${index}`}><div><strong>{readable(run.mode)} · {readable(run.status)}</strong><small>{run.model} · {run.promptVersion} · {run.configVersionId}</small></div><p>{run.summary || readable(run.errorCode || "No validated result")}</p><small>{readable(run.outcome || "no outcome")} · {run.policyCode || "no policy"} · {run.estimatedCostMicrousd || 0} µUSD{run.uncertain ? " · escalated for uncertainty" : ""}</small></article>)}</div></section> : null}{!timeline.length && !workflowRuns.length ? <p>No activity has been loaded for this case.</p> : null}</>,
251
+ };
252
+ return <>
231
253
 
232
- {aiRuns.length ? <section><h3>Bounded AI record</h3><div className="ai-run-list">{aiRuns.map((run, index) => <article key={run.id ?? `${run.model}-${index}`}><div><strong>{readable(run.mode)} · {readable(run.status)}</strong><small>{run.model} · {run.promptVersion} · {run.configVersionId}</small></div><p>{run.summary || readable(run.errorCode || "No validated result")}</p><small>{readable(run.outcome || "no outcome")} · {run.policyCode || "no policy"} · {run.estimatedCostMicrousd || 0} µUSD{run.uncertain ? " · escalated for uncertainty" : ""}</small></article>)}</div></section> : null}
254
+ {hasDraft ? <p className="private-draft-status" role="status">Private draft saved in this tab. It stays here if you return or transfer the case.</p> : null}
255
+ {report.handoffs?.[0] ? <details className="latest-handoff"><summary>Latest handoff · {report.handoffs[0].authorName}</summary><p>{report.handoffs[0].note}</p></details> : null}
256
+ {report.partialLoadErrors?.length ? <p className="error case-load-warning" role="alert">Some case information could not refresh: {report.partialLoadErrors.join(", ")}. Previously loaded information is shown where available. Use Refresh case to retry.</p> : null}
257
+ <div className="case-section-tabs" ref={tabsRef} role="tablist" aria-label="Case sections">{tabs.map(([key,label],index) => <button key={key} id={`case-tab-${key}`} role="tab" type="button" aria-selected={caseTab === key} aria-controls={`case-panel-${key}`} tabIndex={caseTab === key ? 0 : -1} onClick={() => selectTab(key!)} onKeyDown={event => {
258
+ const next = event.key === "ArrowRight" ? (index+1)%tabs.length : event.key === "ArrowLeft" ? (index+tabs.length-1)%tabs.length : event.key === "Home" ? 0 : event.key === "End" ? tabs.length-1 : null;
259
+ if (next === null) return;
260
+ event.preventDefault(); selectTab(tabs[next]![0]!); tabsRef.current?.querySelectorAll<HTMLButtonElement>('button[role="tab"]')[next]?.focus({ preventScroll:true });
261
+ }}>{label}</button>)}</div>
262
+ {caseTab === "overview" ? <section className="case-summary" aria-label="Case summary"><div><span className="context-label">Reported allegation</span><h3>{readable(report.reasonCode)} · {readable(report.targetType)}</h3><p>{report.targetReference}</p></div><div className="case-summary-facts"><span>{evidence.length} evidence reference{evidence.length === 1 ? "" : "s"}</span><span>{findings.length} retrieved finding{findings.length === 1 ? "" : "s"}</span><span>{primaryRun ? `${primaryRun.workflowName || primaryRun.workflowKey || "Playbook"} · version ${primaryRun.workflowVersion || "pinned"}` : "Direct human review"}</span></div>{!decisions.length ? <button className="secondary" type="button" onClick={() => selectTab("review")}>Open next step</button> : <button className="secondary" type="button" onClick={() => selectTab("outcome")}>{unconfirmedActions ? "Check action confirmation" : "View outcome"}</button>}</section> : null}
263
+ {tabs.map(([key]) => <div className="case-section-panel" id={`case-panel-${key}`} role="tabpanel" aria-labelledby={`case-tab-${key}`} key={key} hidden={caseTab !== key} tabIndex={0}>{panels[key!]}</div>)}
233
264
 
234
- <div className="claim-bar"><p>{activeClaim && report.claim ? <><AnalystAvatar analyst={report.claim.analyst ?? { displayName: "Another reviewer" }} size="small" />{mine ? "You hold" : `${report.claim.analyst?.displayName || "Another reviewer"} holds`} this report until {new Date(report.claim.expiresAt).toLocaleTimeString()}.</> : "This report must be claimed before a reviewer changes it."}</p><div>{mine ? <button className="secondary" type="button" disabled={changingClaim} onClick={dispatchReleaseReport}>{pendingAction === "release" ? "Releasing…" : "Release"}</button> : null}{claimable && !activeClaim ? <button className="primary" type="button" disabled={changingClaim} onClick={dispatchClaimReport}>{pendingAction === "claim" ? "Claiming…" : "Claim report"}</button> : null}</div></div>
265
+ </>;
235
266
 
236
- <div className="detail-actions">
237
- <form onSubmit={sendMessage}><h3>Ask the reporter</h3><p className="form-help">The update is emailed when the reporter supplied an address. Otherwise, Resolve uses the configured signed customer notification channel.</p><label>Message<textarea maxLength={4000} required value={message} onChange={(event) => setMessage(event.currentTarget.value)} disabled={!canMessage || pendingAction === "message"} /></label><label className="checkbox"><input type="checkbox" checked={awaitReporter} onChange={(event) => setAwaitReporter(event.currentTarget.checked)} disabled={!canMessage || pendingAction === "message"} />Move to awaiting reporter</label><button className="secondary" type="submit" disabled={!canMessage || pendingAction === "message"}>{pendingAction === "message" ? "Queueing…" : "Send participant update"}</button></form>
238
- <form onSubmit={recordDecision}><h3>Record a human decision</h3><label>Decision outcome<select value={decisionCode} onChange={(event) => setDecisionCode(event.currentTarget.value)} disabled={!canDecide || pendingAction === "decision"} required>{decisionOptions.map((option) => <option value={option.value} key={option.value}>{option.label}</option>)}</select></label><label>Policy code<select value={policyCode} onChange={(event) => setPolicyCode(event.currentTarget.value)} disabled={!canDecide || pendingAction === "decision"} required>{rules.map((rule) => <option value={rule.code} key={rule.code}>{rule.title ? `${rule.code} · ${rule.title}` : rule.code}</option>)}</select></label><label>Internal rationale<textarea maxLength={8000} value={rationale} onChange={(event) => setRationale(event.currentTarget.value)} disabled={!canDecide || pendingAction === "decision"} required /></label><label>Reporter outcome<textarea maxLength={4000} value={reporterNotice} onChange={(event) => setReporterNotice(event.currentTarget.value)} disabled={!canDecide || pendingAction === "decision"} required /></label><label>Affected-user notice <small>Optional; sent through a separate audience channel.</small><textarea maxLength={4000} value={affectedNotice} onChange={(event) => setAffectedNotice(event.currentTarget.value)} disabled={!canDecide || pendingAction === "decision"} /></label>{actionCodes.length ? <label>Application action <small>Optional. Only deployment-allowlisted actions are shown.</small><select value={actionCode} onChange={(event) => setActionCode(event.currentTarget.value)} disabled={!canDecide || pendingAction === "decision"}><option value="">No application action</option>{actionCodes.map((code) => <option value={code} key={code}>{readable(code)}</option>)}</select></label> : null}<button className="primary" type="submit" disabled={!canDecide || pendingAction === "decision"}>{pendingAction === "decision" ? "Recording…" : "Complete human review"}</button></form>
239
- </div>
240
- <p className="mutation-status" role="status">{statusMessage}</p>
241
- <p className="error" role="alert">{error}</p>
242
- </>
243
- );
244
267
  }
245
268
 
246
269
  export function ReportDrawer() {
247
270
  const snapshot = useSyncExternalStore(subscribeToReportWorkspace, getReportWorkspaceSnapshot, getReportWorkspaceSnapshot);
248
271
  const detail = snapshot.detail;
249
272
  const open = Boolean(detail.reportId);
273
+ const dialogRef = useRef<HTMLDialogElement>(null);
274
+ useEffect(() => {
275
+ if (!open) return;
276
+ const previous = document.activeElement;
277
+ const background = [...document.querySelectorAll<HTMLElement>("main, #workspace-shell-root")].map(element => ({ element, inert:element.inert }));
278
+ for (const item of background) item.element.inert=true;
279
+ dialogRef.current?.querySelector<HTMLElement>("h2")?.focus({ preventScroll:true });
280
+ return () => {
281
+ for (const item of background) item.element.inert=item.inert;
282
+ if (previous instanceof HTMLElement && previous.isConnected) previous.focus({ preventScroll:true });
283
+ };
284
+ }, [open]);
250
285
  const collaborators = detail.report ? otherReviewersOnReport(snapshot.presence, detail.report.id, snapshot.actorId) : [];
251
286
  return (
252
- <dialog className="detail-dialog report-drawer" open={open} aria-labelledby="report-detail-title" onCancel={(event) => { event.preventDefault(); dispatchCloseReport(); }} data-react-slice="report-drawer">
253
- <div className="detail-head"><div><h2 id="report-detail-title">{detail.report?.reference || "Report"}</h2><p>{detail.report ? `${readable(detail.report.state)} · ${readable(detail.report.queueId || "unassigned")}` : "Loading report…"}</p><ReportCollaborators analysts={collaborators} /></div><button className="icon-button" type="button" aria-label="Close report detail" onClick={dispatchCloseReport}>×</button></div>
254
- <div className="report-detail-content">{detail.loading && !detail.report ? <div className="skeleton-block" role="status" aria-label="Loading report detail" /> : detail.report ? <DetailContent report={detail.report} actorId={snapshot.actorId} generatedAt={snapshot.generatedAt} actionCodes={snapshot.actionCodes} claimReports={snapshot.permissions.claimReports} messageReporters={snapshot.permissions.messageReporters} decideReports={snapshot.permissions.decideReports} readEvidence={snapshot.permissions.readEvidence} pendingAction={detail.pendingAction} messageMutationVersion={detail.messageMutationVersion} statusMessage={detail.statusMessage} error={detail.error} /> : detail.error ? <p className="error surface-error" role="alert">{detail.error}</p> : null}</div>
287
+ <dialog ref={dialogRef} onKeyDown={event => {
288
+ if (event.key !== "Tab" || (event.target instanceof Element && event.target.closest(".case-handoff-dialog"))) return;
289
+ const controls = [...(dialogRef.current?.querySelectorAll<HTMLElement>('button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), a[href], [tabindex="0"]') || [])].filter(element => element.tabIndex >= 0 && element.getClientRects().length > 0);
290
+ const first=controls[0], last=controls.at(-1);
291
+ if (event.shiftKey && (document.activeElement === first || document.activeElement?.tagName === "H2")) { event.preventDefault(); last?.focus(); }
292
+ else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first?.focus(); }
293
+ }} className="detail-dialog report-drawer" open={open} aria-labelledby="report-detail-title" aria-modal={open ? true : undefined} onCancel={(event) => { event.preventDefault(); dispatchCloseReport(); }} data-react-slice="report-drawer">
294
+ <div className="detail-head"><div><h2 id="report-detail-title" tabIndex={-1}>{detail.report?.reference || "Report"}</h2><p>{detail.report ? `${readable(detail.report.state)} · ${(snapshot.queues.find(queue => queue.id === detail.report!.queueId)?.name || readable(detail.report.queueId || "unassigned"))}` : "Loading report…"}</p><ReportCollaborators analysts={collaborators} /></div><div className="case-header-actions">{detail.reportId ? <button className="secondary case-refresh" type="button" aria-label="Refresh case" title="Refresh case" onClick={() => dispatchOpenReport(detail.reportId!)}><span aria-hidden="true">↻</span><span className="case-refresh-label">Refresh case</span></button> : null}<button className="icon-button" type="button" aria-label="Close report detail" onClick={dispatchCloseReport}>×</button></div></div>
295
+ {detail.report ? <CaseOwnership key={detail.report.id} snapshot={snapshot} /> : null}
296
+ <div className="report-detail-content">{detail.loading && !detail.report ? <div className="skeleton-block" role="status" aria-label="Loading report detail" /> : detail.report ? <DetailContent key={detail.report.id} reviewRequest={detail.reviewRequest || 0} destination={detail.destination} report={detail.report} actorId={snapshot.actorId} actionCodes={snapshot.actionCodes} messageReporters={snapshot.permissions.messageReporters} decideReports={snapshot.permissions.decideReports} readEvidence={snapshot.permissions.readEvidence} pendingAction={detail.pendingAction} messageMutationVersion={detail.messageMutationVersion} /> : detail.error ? <p className="error surface-error" role="alert">{detail.error}</p> : null}</div>
255
297
  </dialog>
256
298
  );
257
299
  }
@@ -1,3 +1,4 @@
1
+ import { priorityName } from "../lib/priority";
1
2
  import { useEffect, useRef, useState, useSyncExternalStore, type ChangeEvent, type MouseEvent } from "react";
2
3
  import { AnalystAvatar, AnalystIdentity } from "../components/AnalystIdentity";
3
4
  import { PageHeader } from "../components/PageHeader";
@@ -7,12 +8,6 @@ import { orderedPresence, partitionPresence, presenceSummaryLabel, reviewersOnRe
7
8
  import { getReportWorkspaceSnapshot, subscribeToReportWorkspace } from "./store";
8
9
  import type { PresenceAnalyst, ReportFilters, ReportPriorityFilter, ReportSort, ReportSummary } from "./types";
9
10
 
10
- function priorityName(priority: number): "high" | "medium" | "low" {
11
- if (priority >= 70) return "high";
12
- if (priority >= 40) return "medium";
13
- return "low";
14
- }
15
-
16
11
  function PresenceGroup({ label, analysts }: { label: string; analysts: PresenceAnalyst[] }) {
17
12
  if (!analysts.length) return null;
18
13
  return (
@@ -100,8 +95,8 @@ function Presence({ analysts }: { analysts: PresenceAnalyst[] }) {
100
95
  }
101
96
 
102
97
  function Owner({ report }: { report: ReportSummary }) {
103
- if (!report.owner) return <span className="unclaimed">Unclaimed</span>;
104
- return <AnalystIdentity analyst={report.owner} detail={report.owner.mine ? "You" : "Claimed"} />;
98
+ if (!report.owner) return <span className="unclaimed">Unassigned</span>;
99
+ return <AnalystIdentity analyst={report.owner} detail={report.owner.mine ? "You" : "Case owner"} />;
105
100
  }
106
101
 
107
102
  function ReportViewers({ analysts }: { analysts: PresenceAnalyst[] }) {
@@ -110,7 +105,7 @@ function ReportViewers({ analysts }: { analysts: PresenceAnalyst[] }) {
110
105
  const names = analysts.map((analyst) => analyst.displayName).join(", ");
111
106
  const label = `${analysts.length} reviewer${analysts.length === 1 ? "" : "s"} viewing this report: ${names}`;
112
107
  return (
113
- <span className="report-row-viewers" aria-label={label} title={label}>
108
+ <span className="report-row-viewers" role="img" aria-label={label} title={label}>
114
109
  <span className="report-row-viewers-desktop" aria-hidden="true">
115
110
  <span className="presence-avatar-stack">
116
111
  {visible.map((analyst) => <AnalystAvatar analyst={analyst} size="small" key={analyst.actorId} />)}
@@ -150,19 +145,23 @@ export function ReportWorkspace() {
150
145
 
151
146
  return (
152
147
  <div data-react-slice="report-workspace">
153
- <PageHeader title="Reports" description="Review incoming reports and assign ownership." accessory={<span className="live-label"><i />Live queue</span>}>
148
+ <PageHeader title="Cases" description="Understand what happened, review the evidence, and follow each outcome." accessory={<span className="live-label"><i />Live queue</span>}>
154
149
  <Presence analysts={snapshot.presence} />
155
150
  </PageHeader>
156
151
 
157
152
  <section className="surface report-surface" aria-labelledby="inbox-title">
153
+ <div className="ownership-filters" role="group" aria-label="Case ownership">
154
+ {([["", "All"], ["mine", "My work"], ["unassigned", "Unassigned"]] as const).map(([value, label]) => <button type="button" key={value} aria-pressed={(snapshot.filters.ownership || "") === value} onClick={() => dispatchReportFilters({ ...snapshot.filters, ownership: value, state: value === "mine" ? "" : "needs_review" })}>{label}</button>)}
155
+ </div>
158
156
  <h2 id="inbox-title" className="visually-hidden">Accepted reports</h2>
159
157
  <div className="surface-head">
160
158
  <div className="queue-toolbar">
161
159
  <label className="search-field"><span aria-hidden="true">⌕</span><input type="search" placeholder="Search reports" aria-label="Search reports" value={snapshot.filters.search} onChange={search} /></label>
162
160
  <label>Queue<select value={snapshot.filters.queueId} onChange={(event) => updateFilter("queueId", event.currentTarget.value)}><option value="">All queues</option>{snapshot.queues.map((queue) => <option value={queue.id} key={queue.id}>{queue.name}</option>)}</select></label>
163
- <label>Priority<select value={snapshot.filters.priority} onChange={(event) => updateFilter("priority", event.currentTarget.value as ReportPriorityFilter)}><option value="">All priorities</option><option value="high">High</option><option value="medium">Medium</option><option value="low">Low</option></select></label>
161
+ <label>Priority<select value={snapshot.filters.priority} onChange={(event) => updateFilter("priority", event.currentTarget.value as ReportPriorityFilter)}><option value="">All priorities</option><option value="urgent">Urgent</option><option value="high">High</option><option value="medium">Medium</option><option value="low">Low</option></select></label>
164
162
  <label>Age<select value={snapshot.filters.age} onChange={(event) => updateFilter("age", event.currentTarget.value as ReportFilters["age"])}><option value="">Any age</option><option value="hour">Under 1 hour</option><option value="day">Under 24 hours</option><option value="older">Older than 24 hours</option></select></label>
165
- <label>State<select value={snapshot.filters.state} onChange={(event) => updateFilter("state", event.currentTarget.value)}><option value="">All states</option><option value="received">Received</option><option value="human_review">Human review</option><option value="awaiting_reporter">Awaiting reporter</option><option value="resolved_by_human">Resolved by human</option></select></label>
163
+ <label>State<select value={snapshot.filters.state} onChange={(event) => updateFilter("state", event.currentTarget.value)}><option value="">All states</option><option value="needs_review">Needs review</option><option value="received">Received</option><option value="human_review">Human review</option><option value="awaiting_reporter">Awaiting reporter</option><option value="resolved_by_human">Resolved by human</option><option value="resolved_by_ai">Resolved by AI</option><option value="human_review_requested">Human review requested</option><option value="appealed">Appealed</option><option value="closed">Closed</option></select></label>
164
+ <label>Owner<select value={snapshot.filters.ownership || ""} onChange={(event) => updateFilter("ownership", event.currentTarget.value as ReportFilters["ownership"])}><option value="">All owners</option><option value="unassigned">Unassigned</option><option value="mine">Assigned to me</option></select></label>
166
165
  <label>Sort<select value={snapshot.filters.sort} onChange={(event) => updateFilter("sort", event.currentTarget.value as ReportSort)}><option value="oldest">Oldest first</option><option value="newest">Newest first</option><option value="priority">Priority first</option></select></label>
167
166
  </div>
168
167
  <button className="icon-button refresh-button" type="button" aria-label="Refresh reports" disabled={snapshot.refreshing} onClick={dispatchReportRefresh}>{snapshot.refreshing ? "…" : "↻"}</button>