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,8 +1,9 @@
1
- import { Fragment, useEffect, useMemo, useState, useSyncExternalStore, type MouseEvent } from "react";
1
+ import { useEffect, useMemo, useRef, useState, useSyncExternalStore, type MouseEvent } from "react";
2
2
  import { createPortal } from "react-dom";
3
3
  import { AnalystIdentity } from "../components/AnalystIdentity";
4
4
  import { dispatchNavigation, dispatchSignOut } from "./events";
5
5
  import { visibleNavigationGroups } from "./navigation";
6
+ import { WorkspaceUpdateNotice } from "./WorkspaceUpdateNotice";
6
7
  import { getShellSnapshot, subscribeToShell } from "./store";
7
8
  import type { ConsoleAnalystProfile, ConsoleSession, CustomerBrand, WorkspaceView } from "./types";
8
9
 
@@ -40,11 +41,12 @@ function useCustomerBrand(): CustomerBrand {
40
41
  }
41
42
 
42
43
  function BrandLogo({ brand, size }: { brand: CustomerBrand; size: "brand" | "workspace" }) {
44
+ const [failedUrl, setFailedUrl] = useState("");
43
45
  const isDefaultBrand = brand.organization_name.trim().toLowerCase() === "safest resolve";
44
46
  const logoUrl = brand.logo_url || (isDefaultBrand ? "/brand-icon.svg" : "");
45
47
  const alt = brand.logo_alt || (isDefaultBrand ? "Safest Resolve logo" : `${brand.organization_name} logo`);
46
- if (logoUrl) {
47
- return <img className={size === "workspace" ? "workspace-logo" : undefined} src={logoUrl} alt={alt} width={size === "workspace" ? 30 : 34} height={size === "workspace" ? 30 : 34} />;
48
+ if (logoUrl && logoUrl !== failedUrl) {
49
+ return <img className={size === "workspace" ? "workspace-logo" : undefined} src={logoUrl} alt={alt} onError={() => setFailedUrl(logoUrl)} width={size === "workspace" ? 30 : 34} height={size === "workspace" ? 30 : 34} />;
48
50
  }
49
51
  return <span className={size === "workspace" ? "workspace-mark" : "customer-brand-fallback"} aria-hidden="true">{initials(brand.organization_name)}</span>;
50
52
  }
@@ -64,40 +66,63 @@ function accessLabel(session: ConsoleSession): string {
64
66
  return session.roles.map((role) => roleLabels[role] ?? role.replaceAll("_", " ")).join(", ") || "Team member";
65
67
  }
66
68
 
67
- function Navigation({ session, currentView, reportCount, open, onClose }: {
69
+ function Navigation({ session, currentView, open, onClose }: {
68
70
  session: ConsoleSession;
69
71
  currentView: WorkspaceView;
70
- reportCount: number;
71
72
  open: boolean;
72
73
  onClose: () => void;
73
74
  }) {
74
75
  const groups = useMemo(() => visibleNavigationGroups(session), [session]);
76
+ const [expanded, setExpanded] = useState<Record<string, boolean>>({});
77
+ const navRef = useRef<HTMLElement>(null);
78
+ useEffect(() => {
79
+ if (!open) return;
80
+ // Safari does not focus buttons on pointer clicks. Restore to the actual
81
+ // navigation trigger instead of relying on the previously focused element.
82
+ const previous = document.querySelector<HTMLButtonElement>(".nav-toggle") ?? document.activeElement;
83
+ navRef.current?.querySelector<HTMLElement>('a[aria-current="page"], a, button')?.focus();
84
+ return () => { if (previous instanceof HTMLElement && previous.isConnected) previous.focus({ preventScroll: true }); };
85
+ }, [open]);
86
+ useEffect(() => {
87
+ // Opening a primary area must not move the next target under the pointer.
88
+ // Reveal a group only when navigating directly to one of its nested tools.
89
+ const active = groups.find(group => (group.administration ? group.items : group.items.slice(1)).some(item => item.view === currentView));
90
+ if (active) setExpanded(current => current[active.label] ? current : ({ ...current, [active.label]: true }));
91
+ }, [currentView, groups]);
75
92
  const navigate = (event: MouseEvent<HTMLAnchorElement>, view: WorkspaceView): void => {
93
+ if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
76
94
  event.preventDefault();
77
95
  dispatchNavigation(view);
78
96
  onClose();
79
97
  };
80
- return (
81
- <>
82
- <aside id="workspace-navigation" className={`sidebar${open ? " open" : ""}`} aria-label="Workspace navigation">
83
- <nav>
84
- {groups.map((group) => (
85
- <Fragment key={group.label}>
86
- <small>{group.label}</small>
87
- {group.items.map((item) => (
88
- <a className={item.view === currentView ? "active" : undefined} data-nav={item.view} href={`#${item.view}`} key={item.view} aria-current={item.view === currentView ? "page" : undefined} onClick={(event) => navigate(event, item.view)}>
89
- <svg aria-hidden="true"><use href={`#${item.icon}`} /></svg>
90
- <span>{item.label}</span>
91
- {item.count === "reports" ? <b id="nav-report-count" className="nav-count">{reportCount}</b> : null}
92
- </a>
93
- ))}
94
- </Fragment>
95
- ))}
96
- </nav>
97
- </aside>
98
- <button className="nav-scrim" type="button" aria-label="Close navigation" hidden={!open} onClick={onClose} />
99
- </>
98
+ const link = (item: (typeof groups)[number]["items"][number], primary = false) => (
99
+ <a className={item.view === currentView ? "active" : undefined} data-nav={item.view} data-primary-nav={primary || undefined} href={`#${item.view}`} key={item.view} aria-current={item.view === currentView ? "page" : undefined} onClick={(event) => navigate(event, item.view)}>
100
+ <svg aria-hidden="true"><use href={`#${item.icon}`} /></svg><span>{item.label}</span>
101
+ </a>
100
102
  );
103
+ return <>
104
+ <aside ref={navRef} id="workspace-navigation" className={`sidebar${open ? " open" : ""}`} role={open ? "dialog" : undefined} aria-modal={open ? true : undefined} aria-label="Workspace navigation" onKeyDown={event => {
105
+ if (!open || event.key !== "Tab") return;
106
+ const controls = [...(navRef.current?.querySelectorAll<HTMLElement>('a, button') || [])].filter(item => item.getClientRects().length > 0);
107
+ const first = controls[0], last = controls.at(-1);
108
+ if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last?.focus(); }
109
+ else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first?.focus(); }
110
+ }}>
111
+ <div className="navigation-mobile-head"><strong>Workspace</strong><button type="button" className="icon-button" aria-label="Close workspace navigation" onClick={onClose}>×</button></div>
112
+ <nav aria-label="Product areas">
113
+ {groups.map(group => {
114
+ const [primary, ...secondary] = group.items;
115
+ const isExpanded = expanded[group.label] === true;
116
+ const panelId = `nav-area-${primary!.view}`;
117
+ return <div className={`navigation-area${group.administration ? " navigation-admin" : ""}`} key={group.label}>
118
+ {group.administration ? <button className="navigation-admin-toggle" type="button" aria-expanded={isExpanded} aria-controls={panelId} onClick={() => setExpanded(current => ({ ...current, [group.label]: !isExpanded }))}><svg aria-hidden="true"><use href="#i-settings" /></svg><span>Workspace settings</span><span aria-hidden="true">{isExpanded ? "−" : "+"}</span></button> : <div className="navigation-primary">{link(primary!, true)}{secondary.length ? <button className="navigation-expand" type="button" aria-label={`${isExpanded ? "Hide" : "Show"} ${group.label} tools`} aria-expanded={isExpanded} aria-controls={panelId} onClick={() => setExpanded(current => ({ ...current, [group.label]: !isExpanded }))}><span aria-hidden="true">{isExpanded ? "−" : "+"}</span></button> : null}</div>}
119
+ <div className="navigation-secondary" id={panelId} hidden={!isExpanded}>{(group.administration ? group.items : secondary).map(item => link(item))}</div>
120
+ </div>;
121
+ })}
122
+ </nav>
123
+ </aside>
124
+ <button className="nav-scrim" type="button" aria-label="Close navigation" tabIndex={-1} hidden={!open} onClick={onClose} />
125
+ </>;
101
126
  }
102
127
 
103
128
  export function WorkspaceShell({ navigationTarget }: { navigationTarget: HTMLElement }) {
@@ -135,14 +160,14 @@ export function WorkspaceShell({ navigationTarget }: { navigationTarget: HTMLEle
135
160
  </button>
136
161
  <a className="brand" href="/" aria-label="Workspace home">
137
162
  <BrandLogo brand={brand} size="brand" />
138
- <span><strong>{brand.organization_name}</strong><small>Reports and workflows</small></span>
163
+ <span><strong>{brand.organization_name}</strong><small>Case resolution</small></span>
139
164
  </a>
140
165
  <div className="top-workspace" aria-label="Current workspace" hidden={!session}>
141
166
  <BrandLogo brand={brand} size="workspace" />
142
- <span><strong>{brand.organization_name}</strong><small>Reports and workflows</small></span>
167
+ <span><strong>{brand.organization_name}</strong><small>Case resolution</small></span>
143
168
  </div>
144
169
  <button className="global-command" type="button" hidden={!session || !canUseAssistant} onClick={() => dispatchNavigation("assistant")}>
145
- <svg aria-hidden="true"><use href="#i-search" /></svg><span>Search or ask Safest</span><kbd>⌘ K</kbd>
170
+ <svg aria-hidden="true"><use href="#i-search" /></svg><span>Ask Safest</span><kbd>⌘ K</kbd>
146
171
  </button>
147
172
  <div className="top-actions">
148
173
  <span className={`health ${snapshot.health.status === "checking" ? "" : snapshot.health.status}`.trim()}><i aria-hidden="true" /><span>{snapshot.health.label}</span></span>
@@ -150,7 +175,8 @@ export function WorkspaceShell({ navigationTarget }: { navigationTarget: HTMLEle
150
175
  {session ? <button className="sign-out-button" type="button" onClick={dispatchSignOut}>Sign out</button> : null}
151
176
  </div>
152
177
  </header>
153
- {session ? createPortal(<Navigation session={session} currentView={snapshot.currentView} reportCount={snapshot.reportCount} open={navigationOpen} onClose={() => setNavigationOpen(false)} />, navigationTarget) : null}
178
+ <WorkspaceUpdateNotice />
179
+ {session ? createPortal(<Navigation session={session} currentView={snapshot.currentView} open={navigationOpen} onClose={() => setNavigationOpen(false)} />, navigationTarget) : null}
154
180
  </>
155
181
  );
156
182
  }
@@ -0,0 +1,48 @@
1
+ import { useEffect, useState } from "react";
2
+
3
+ export function WorkspaceUpdateNotice() {
4
+ const [available, setAvailable] = useState(false);
5
+ useEffect(() => {
6
+ const current = document.querySelector<HTMLMetaElement>('meta[name="safest-release"]')?.content;
7
+ if (!current) return;
8
+ let stopped = false;
9
+ let pending = false;
10
+ let lastCheck = 0;
11
+ const controller = new AbortController();
12
+ const check = async () => {
13
+ if (stopped || pending || document.visibilityState === "hidden" || Date.now() - lastCheck < 30_000) return;
14
+ pending = true;
15
+ lastCheck = Date.now();
16
+ try {
17
+ const response = await fetch("/console/release.json", { cache: "no-store", signal: controller.signal });
18
+ if (!response.ok) return;
19
+ const value: unknown = await response.json();
20
+ if (!stopped && value && typeof value === "object" && "release" in value && typeof value.release === "string" && /^[a-f0-9]{20}$/.test(value.release)) {
21
+ setAvailable(value.release !== current);
22
+ }
23
+ } catch { /* Offline work must remain usable; retry on the next check. */ }
24
+ finally { pending = false; }
25
+ };
26
+ void check();
27
+ const timer = window.setInterval(() => { void check(); }, 60_000);
28
+ const resume = () => { void check(); };
29
+ window.addEventListener("pageshow", resume);
30
+ window.addEventListener("focus", resume);
31
+ window.addEventListener("online", resume);
32
+ document.addEventListener("visibilitychange", resume);
33
+ return () => {
34
+ stopped = true;
35
+ controller.abort();
36
+ window.clearInterval(timer);
37
+ window.removeEventListener("pageshow", resume);
38
+ window.removeEventListener("focus", resume);
39
+ window.removeEventListener("online", resume);
40
+ document.removeEventListener("visibilitychange", resume);
41
+ };
42
+ }, []);
43
+ if (!available) return null;
44
+ return <aside className="workspace-update" aria-label="Workspace update">
45
+ <p role="status"><strong>An update is ready.</strong> Save your work, then refresh to get the latest improvements.</p>
46
+ <button className="secondary" type="button" onClick={() => window.location.reload()}>Refresh workspace</button>
47
+ </aside>;
48
+ }
@@ -16,11 +16,14 @@ interface SessionResponse {
16
16
  }
17
17
 
18
18
  const viewTitles: Partial<Record<WorkspaceView, string>> = {
19
- command: "Command centre",
19
+ command: "Today",
20
+ reports: "Cases",
21
+ workflows: "Playbooks",
22
+ components: "Connections",
20
23
  queues: "Queues and policies",
21
24
  assistant: "Ask Safest",
22
25
  "ai-quality": "AI quality",
23
- analytics: "Reports and outcomes",
26
+ analytics: "Insights",
24
27
  settings: "Settings",
25
28
  };
26
29
 
@@ -56,11 +59,11 @@ function allowedViews(session: ConsoleSession): Set<WorkspaceView> {
56
59
  }
57
60
 
58
61
  function setDocumentTitle(view: WorkspaceView): void {
59
- const suffix = getShellSnapshot().session ? viewTitles[view] || readable(view) : "Reports and workflows";
62
+ const suffix = getShellSnapshot().session ? viewTitles[view] || readable(view) : "Case resolution";
60
63
  document.title = `${brand.organization_name || "Safest Resolve"} — ${suffix}`;
61
64
  }
62
65
 
63
- function setView(requested: string): void {
66
+ function setView(requested: string, addHistory = false): void {
64
67
  const snapshot = getShellSnapshot();
65
68
  const session = snapshot.session;
66
69
  if (!session) return;
@@ -71,9 +74,13 @@ function setView(requested: string): void {
71
74
  ? requestedView
72
75
  : "command";
73
76
  for (const section of available) section.hidden = section.dataset.view !== selected;
74
- if (window.location.hash !== `#${selected}`) history.replaceState(null, "", `#${selected}`);
77
+ if (window.location.hash !== `#${selected}`) {
78
+ if (addHistory) history.pushState(null, "", `#${selected}`);
79
+ else history.replaceState(null, "", `#${selected}`);
80
+ }
75
81
  setShellSnapshot((current) => ({ ...current, currentView: selected }));
76
82
  setDocumentTitle(selected);
83
+ if (addHistory) window.scrollTo({ top: 0, left: 0, behavior: "instant" });
77
84
  }
78
85
 
79
86
  function expireWorkspaceSession(message = ""): void {
@@ -128,7 +135,7 @@ async function initialize(): Promise<void> {
128
135
  const accountRoute = accountRouteFromHash(window.location.hash);
129
136
  if (accountRoute.view !== "login") showWorkspace(false);
130
137
  try {
131
- brand = await window.SafestBrand?.loadBrand("/v1/brand", { title: "Reports and workflows" }) || brand;
138
+ brand = await window.SafestBrand?.loadBrand("/v1/brand", { title: "Case resolution" }) || brand;
132
139
  } catch {
133
140
  brand = window.SafestBrand?.defaults || brand;
134
141
  }
@@ -163,17 +170,24 @@ export function startShellController(): void {
163
170
  window.addEventListener(shellEvents.signOut, () => { void signOut(); });
164
171
  window.addEventListener(shellEvents.navigate, (event) => {
165
172
  const view = event instanceof CustomEvent && typeof event.detail?.view === "string" ? event.detail.view : "command";
166
- setView(view);
173
+ setView(view, true);
167
174
  if (view === "assistant") window.dispatchEvent(new Event("safest:assistant:focus"));
168
175
  });
169
176
  window.addEventListener("keydown", (event) => {
170
177
  if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k" && getShellSnapshot().session?.permissions.useAssistant) {
171
178
  event.preventDefault();
172
- setView("assistant");
179
+ setView("assistant", true);
173
180
  window.dispatchEvent(new Event("safest:assistant:focus"));
174
181
  }
175
182
  });
176
183
  window.addEventListener("hashchange", () => {
184
+ if (window.location.hash === "#main") {
185
+ const view = getShellSnapshot().currentView;
186
+ const section = document.querySelector<HTMLElement>(`[data-view="${view}"]`);
187
+ if (section) { section.tabIndex = -1; section.focus(); }
188
+ history.replaceState(null, "", `#${view}`);
189
+ return;
190
+ }
177
191
  if (getShellSnapshot().session && !window.location.hash.startsWith("#invite=")) setView(window.location.hash.slice(1) || "reports");
178
192
  });
179
193
  void initialize();
@@ -5,57 +5,48 @@ export interface NavigationItem {
5
5
  label: string;
6
6
  icon: string;
7
7
  visible: (session: ConsoleSession) => boolean;
8
- count?: "reports";
9
8
  }
10
-
11
9
  export interface NavigationGroup {
12
10
  label: string;
13
11
  items: NavigationItem[];
12
+ administration?: boolean;
14
13
  }
15
-
16
14
  const always = (): boolean => true;
17
15
  const permission = (name: string) => (session: ConsoleSession): boolean => session.permissions[name] === true;
18
16
  const anyPermission = (...names: string[]) => (session: ConsoleSession): boolean => names.some((name) => session.permissions[name] === true);
19
17
 
18
+ // Stable route names preserve existing bookmarks. The first item is the area;
19
+ // the other items are specialist tools belonging to that area.
20
20
  export const navigationGroups: NavigationGroup[] = [
21
- {
22
- label: "Work",
23
- items: [
24
- { view: "command", label: "Command centre", icon: "i-home", visible: always },
25
- { view: "reports", label: "Reports", icon: "i-inbox", visible: always, count: "reports" },
26
- { view: "queues", label: "Queues and policies", icon: "i-queue", visible: permission("readReports") },
27
- { view: "appeals", label: "Appeals", icon: "i-appeal", visible: permission("reviewAppeals") },
28
- ],
29
- },
30
- {
31
- label: "Build",
32
- items: [
33
- { view: "workflows", label: "Workflows", icon: "i-workflow", visible: permission("manageWorkflows") },
34
- { view: "components", label: "Tools & connections", icon: "i-component", visible: permission("manageComponents") },
35
- { view: "configuration", label: "Configuration", icon: "i-settings", visible: permission("manageConfiguration") },
36
- ],
37
- },
38
- {
39
- label: "Observe",
40
- items: [
41
- { view: "assistant", label: "Ask Safest", icon: "i-spark", visible: permission("useAssistant") },
42
- { view: "ai-quality", label: "AI quality", icon: "i-quality", visible: permission("decideReports") },
43
- { view: "operations", label: "Operations", icon: "i-activity", visible: permission("readOperations") },
44
- { view: "analytics", label: "Reports and outcomes", icon: "i-chart", visible: permission("readAnalytics") },
45
- ],
46
- },
47
- {
48
- label: "Admin",
49
- items: [
50
- { view: "settings", label: "Settings", icon: "i-settings", visible: anyPermission("manageInstallation", "manageConfiguration", "manageIntegrations") },
51
- { view: "people", label: "People and roles", icon: "i-people", visible: permission("managePeople") },
52
- { view: "profile", label: "My profile", icon: "i-user", visible: (session) => session.actor.type === "operator_user" },
53
- ],
54
- },
21
+ { label: "Today", items: [
22
+ { view: "command", label: "Today", icon: "i-home", visible: always },
23
+ { view: "assistant", label: "Ask Safest", icon: "i-spark", visible: permission("useAssistant") },
24
+ ] },
25
+ { label: "Cases", items: [
26
+ { view: "reports", label: "Cases", icon: "i-inbox", visible: permission("readReports") },
27
+ { view: "appeals", label: "Appeals", icon: "i-appeal", visible: permission("reviewAppeals") },
28
+ { view: "queues", label: "Queues and policies", icon: "i-queue", visible: permission("readReports") },
29
+ ] },
30
+ { label: "Playbooks", items: [
31
+ { view: "workflows", label: "Playbooks", icon: "i-workflow", visible: permission("manageWorkflows") },
32
+ { view: "configuration", label: "Configuration", icon: "i-settings", visible: permission("manageConfiguration") },
33
+ ] },
34
+ { label: "Connections", items: [
35
+ { view: "components", label: "Connections", icon: "i-component", visible: permission("manageComponents") },
36
+ ] },
37
+ { label: "Insights", items: [
38
+ { view: "analytics", label: "Insights", icon: "i-chart", visible: permission("readAnalytics") },
39
+ { view: "ai-quality", label: "AI quality", icon: "i-quality", visible: permission("reviewQuality") },
40
+ { view: "operations", label: "Operations", icon: "i-activity", visible: permission("readOperations") },
41
+ ] },
42
+ { label: "Workspace settings", administration: true, items: [
43
+ { view: "settings", label: "Settings", icon: "i-settings", visible: anyPermission("manageInstallation", "manageConfiguration", "manageIntegrations") },
44
+ { view: "people", label: "People and roles", icon: "i-people", visible: permission("managePeople") },
45
+ { view: "profile", label: "My profile", icon: "i-user", visible: (session) => session.actor.type === "operator_user" },
46
+ ] },
55
47
  ];
56
48
 
57
49
  export function visibleNavigationGroups(session: ConsoleSession): NavigationGroup[] {
58
- return navigationGroups
59
- .map((group) => ({ ...group, items: group.items.filter((item) => item.visible(session)) }))
50
+ return navigationGroups.map((group) => ({ ...group, items: group.items.filter((item) => item.visible(session)) }))
60
51
  .filter((group) => group.items.length > 0);
61
52
  }
@@ -0,0 +1,73 @@
1
+ import { useEffect, useRef, useState, type FormEvent } from "react";
2
+ import { compilePlaybook, playbookStarterFixtures, starterPlaybook } from "../../shared/playbooks";
3
+ import { errorMessage, mutationHeaders, requestJson } from "../lib/http";
4
+ import { announceWorkflowChanged, openWorkflowStudio } from "./events";
5
+ import { guideSteps, PlaybookGuideEditor, usePlaybookResources } from "./PlaybookGuideEditor";
6
+ import type { WorkflowCreateResponse } from "./types";
7
+ import { validateGuideInputs } from "./PlaybookFields";
8
+
9
+ export const openPlaybookCreate = () => window.dispatchEvent(new CustomEvent("safest:playbook:create"));
10
+
11
+ export function CreatePlaybookDialog() {
12
+ const dialogRef = useRef<HTMLDialogElement>(null);
13
+ const nameRef = useRef<HTMLInputElement>(null);
14
+ const pendingRef = useRef(false);
15
+ const requestKey = useRef(crypto.randomUUID());
16
+ const createdRef = useRef<WorkflowCreateResponse | null>(null);
17
+ const [open, setOpen] = useState(false);
18
+ const [name, setName] = useState("");
19
+ const [startingPoint, setStartingPoint] = useState<"unwanted-contact" | "description" | "policy">("unwanted-contact");
20
+ const [guide, setGuide] = useState(() => starterPlaybook("unwanted-contact"));
21
+ const [step, setStep] = useState(-1);
22
+ const [pending, setPending] = useState(false);
23
+ const [error, setError] = useState("");
24
+ const { resources, error: resourceError, reload } = usePlaybookResources(open);
25
+ useEffect(() => {
26
+ const show = () => { setOpen(true); setStep(-1); setName(""); setStartingPoint("unwanted-contact"); setGuide(starterPlaybook("unwanted-contact")); setError(""); requestKey.current = crypto.randomUUID(); createdRef.current = null; };
27
+ window.addEventListener("safest:playbook:create", show);
28
+ return () => window.removeEventListener("safest:playbook:create", show);
29
+ }, []);
30
+ useEffect(() => {
31
+ if (open && !dialogRef.current?.open) { dialogRef.current?.showModal(); requestAnimationFrame(() => nameRef.current?.focus()); }
32
+ if (!open && dialogRef.current?.open) dialogRef.current.close();
33
+ }, [open]);
34
+ const dirty = Boolean(name || startingPoint !== "unwanted-contact" || JSON.stringify(guide) !== JSON.stringify(starterPlaybook("unwanted-contact")));
35
+ const close = () => { if (!pendingRef.current && (!dirty || window.confirm("Discard this unsaved playbook draft?"))) setOpen(false); };
36
+ useEffect(() => {
37
+ const unload = (event: BeforeUnloadEvent) => { if (open && (dirty || pendingRef.current)) event.preventDefault(); };
38
+ window.addEventListener("beforeunload", unload);
39
+ return () => window.removeEventListener("beforeunload", unload);
40
+ }, [open, dirty]);
41
+ const submit = async (event: FormEvent<HTMLFormElement>) => {
42
+ event.preventDefault();
43
+ if (pendingRef.current) return;
44
+ if (step < guideSteps.length - 1) { setStep(current => current + 1); return; }
45
+ pendingRef.current = true; setPending(true); setError("");
46
+ try {
47
+ validateGuideInputs(guide, resources);
48
+ const graph = compilePlaybook(guide);
49
+ const key = `${name.toLowerCase().replace(/[^a-z0-9]+/gu, "-").replace(/^-|-$/gu, "").slice(0, 70) || "playbook"}-${requestKey.current.slice(0, 8)}`;
50
+ const result = createdRef.current ?? await requestJson<WorkflowCreateResponse>("/v1/admin/workflows", { method: "POST", headers: { ...mutationHeaders(), "idempotency-key": requestKey.current }, body: JSON.stringify({ key, name, description: guide.summary, purpose: "report", graph }) });
51
+ createdRef.current = result;
52
+ for (const [index, fixture] of playbookStarterFixtures(guide).entries()) await requestJson(`/v1/admin/workflows/${encodeURIComponent(result.workflowId)}/fixtures`, { method: "POST", headers: { ...mutationHeaders(), "idempotency-key": `${requestKey.current}:example:${index}` }, body: JSON.stringify(fixture) });
53
+ setOpen(false); announceWorkflowChanged(); openWorkflowStudio(result.workflowId);
54
+ } catch (cause) { setError(errorMessage(cause, createdRef.current ? "The draft was saved, but its examples could not be added. Retry to finish without creating another draft." : "The playbook could not be created. Your changes are still here.")); }
55
+ finally { pendingRef.current = false; setPending(false); }
56
+ };
57
+ return <dialog ref={dialogRef} className="editor-dialog playbook-create-dialog" aria-labelledby="playbook-create-title" onCancel={event => { event.preventDefault(); close(); }}>
58
+ <div className="detail-head"><div><h2 id="playbook-create-title">Create a playbook</h2><p>Teach Safest how your team handles a situation.</p></div><button className="icon-button" type="button" aria-label="Close playbook creation" disabled={pending} onClick={close}>×</button></div>
59
+ <form className="editor-form" onSubmit={event => void submit(event)}>
60
+ {step < 0 ? <>
61
+ <label>Playbook name<input ref={nameRef} required maxLength={160} value={name} disabled={pending} onChange={event => setName(event.currentTarget.value)} placeholder="For example, unwanted contact" /></label>
62
+ <fieldset><legend>Choose a starting point</legend>{([{ id: "unwanted-contact", name: "Unwanted contact", detail: "Reports of repeated contact, reviewed with context and a clear human decision." }, { id: "description", name: "Describe your own situation", detail: "Describe what your team needs to resolve, then choose evidence and outcomes." }, { id: "policy", name: "Start from your policy", detail: "Paste the relevant policy, including exceptions, then connect it to a published rule." }] as const).map(starter => <label className="template-choice" key={starter.id}><input type="radio" name="playbook-starter" checked={startingPoint === starter.id} onChange={() => { if (startingPoint !== starter.id && (guide.summary !== starterPlaybook(startingPoint === "unwanted-contact" ? "unwanted-contact" : "general-review").summary || guide.policy_text !== starterPlaybook("general-review").policy_text) && !window.confirm("Replace the entered description and policy with this starting point?")) return; setStartingPoint(starter.id); setGuide(starterPlaybook(starter.id === "unwanted-contact" ? "unwanted-contact" : "general-review")); }} /><span><strong>{starter.name}</strong><small>{starter.detail}</small></span></label>)}</fieldset>
63
+ {startingPoint === "description" ? <label>Describe the situation<textarea required rows={5} maxLength={2000} value={guide.summary} onChange={event => setGuide({ ...guide, summary: event.currentTarget.value })} /><small>Explain the concern, what evidence matters and when someone should review it. The next steps turn your description into explicit choices.</small></label> : startingPoint === "policy" ? <label>Paste your policy<textarea required rows={7} maxLength={20000} value={guide.policy_text} onChange={event => setGuide({ ...guide, policy_text: event.currentTarget.value })} /><small>The full text stays in the draft. You will choose its scope, published rule and permitted outcomes before testing.</small></label> : null}
64
+ <div className="playbook-callout"><strong>A draft first, then examples and publication.</strong><p>Nothing starts handling cases until a published version is activated.</p></div>
65
+ </> : <>
66
+ <ol className="playbook-progress" aria-label="Playbook creation steps">{guideSteps.map((label, index) => <li key={label} aria-current={index === step ? "step" : undefined}><span>{index + 1}</span>{label}</li>)}</ol>
67
+ <PlaybookGuideEditor value={guide} onChange={setGuide} step={step} resources={resources} disabled={pending || Boolean(createdRef.current)} />
68
+ </>}
69
+ {resourceError ? <div role="alert" className="playbook-callout">{resourceError} <button className="text-button" type="button" onClick={reload}>Retry loading connections</button></div> : null}
70
+ <div className="editor-footer"><p className="error" role="alert">{error}</p><div><button className="secondary" type="button" disabled={pending || Boolean(createdRef.current)} onClick={() => step < 0 ? close() : setStep(current => current - 1)}>{step < 0 ? "Cancel" : "Back"}</button><button className="primary" type="submit" disabled={pending}>{pending ? "Creating…" : step === guideSteps.length - 1 ? "Create draft and examples" : "Continue"}</button></div></div>
71
+ </form>
72
+ </dialog>;
73
+ }
@@ -0,0 +1,160 @@
1
+ import { useEffect, useRef, useState, type FormEvent } from "react";
2
+ import { playbookStarterFixtures } from "../../shared/playbooks";
3
+ import { readable } from "../lib/format";
4
+ import { errorMessage, mutationHeaders, requestJson } from "../lib/http";
5
+ import type { JsonObject, WorkflowFixture, WorkflowGraph, WorkflowTestResponse } from "./types";
6
+
7
+ type SavedTest = WorkflowTestResponse & { fixtureId: string; fixtureRevision: number; finishedAt: string };
8
+ const object = (value: unknown): JsonObject => value !== null && typeof value === "object" && !Array.isArray(value) ? value as JsonObject : {};
9
+ const pretty = (value: unknown): string => JSON.stringify(value ?? {}, null, 2);
10
+ function parseObject(value: string, label: string): JsonObject {
11
+ let parsed: unknown;
12
+ try { parsed = JSON.parse(value); } catch { throw new Error(`${label} must be valid JSON. Your changes are still here.`); }
13
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error(`${label} must be a JSON object.`);
14
+ return parsed as JsonObject;
15
+ }
16
+
17
+ function ExampleResult({ test, current, graph }: { test?: SavedTest; current: boolean; graph: WorkflowGraph }) {
18
+ if (!test) return <div className="playbook-callout"><strong>Not tested yet</strong><p>Run the examples to see which path this case takes.</p></div>;
19
+ const result = test.result;
20
+ const outcomeName = (code: string): string => graph.nodes.find(node => node.kind === "end" && node.config.terminal_code === code)?.name ?? readable(code);
21
+ const terminal = result?.terminalCode ?? "No outcome reached";
22
+ const expected = result?.expected;
23
+ const names = new Map(graph.nodes.map(node => [node.id, node.name]));
24
+ return <section className={`playbook-example-result ${test.status}`} aria-label="Example result">
25
+ <div className="playbook-panel-heading"><h4>{current ? readable(test.status) : "Needs a new test"}</h4><span>{new Date(test.finishedAt).toLocaleString()}</span></div>
26
+ {!current ? <p>The draft or example has changed since this result. Run the examples again before publishing.</p> : null}
27
+ <dl className="playbook-result-facts"><div><dt>Actual outcome</dt><dd>{outcomeName(terminal)}</dd></div><div><dt>Expected outcome</dt><dd>{typeof expected?.terminal_code === "string" ? outcomeName(expected.terminal_code) : "See expected milestones below"}</dd></div></dl>
28
+ {result?.error ? <p role="status">Stopped at {names.get(result.error.nodeId ?? "") ?? result.error.nodeId ?? "a workflow step"}: {readable(result.error.code)}.</p> : null}
29
+ {result?.expectedMatched === false ? <p role="status">The result did not match the expected outcome or milestones. Check the path below before changing the expectation.</p> : null}
30
+ <ol className="playbook-step-trace">{result?.steps?.map((step, index) => <li key={`${step.nodeId}-${index}`}><span>{names.get(step.nodeId ?? "") ?? readable(step.nodeId ?? "Step")}</span><strong>{readable(step.port ?? step.state ?? "completed")}</strong></li>)}</ol>
31
+ </section>;
32
+ }
33
+
34
+ interface ExampleDraft { name: string; input: string; componentResults: string; humanResponses: string; expected: string; failureInjection: string }
35
+ function draftOf(fixture: WorkflowFixture): ExampleDraft {
36
+ return { name: fixture.name, input: pretty(fixture.input), componentResults: pretty(fixture.componentResults), humanResponses: pretty(fixture.humanResponses), expected: pretty(fixture.expected), failureInjection: pretty(fixture.failureInjection) };
37
+ }
38
+
39
+ export function PlaybookExamples({ workflowId, graph, graphDigest, epoch, disabled, readOnly, onPending, onDirty, onChanged, onRun }: {
40
+ workflowId: string; graph: WorkflowGraph; graphDigest?: string; epoch: number; disabled: boolean; readOnly: boolean;
41
+ onPending: (pending: boolean) => void; onDirty: (dirty: boolean) => void; onChanged: () => void; onRun: () => void;
42
+ }) {
43
+ const [examples, setExamples] = useState<WorkflowFixture[]>([]);
44
+ const [tests, setTests] = useState<SavedTest[]>([]);
45
+ const resultRef = useRef<HTMLDivElement>(null);
46
+ const saveRequest = useRef<{ fingerprint: string; key: string } | null>(null);
47
+ const seenEpoch = useRef(epoch);
48
+ const [loadedEpoch, setLoadedEpoch] = useState(epoch);
49
+ const [selected, setSelected] = useState<string | null>(null);
50
+ const [draft, setDraft] = useState<ExampleDraft | null>(null);
51
+ const [loading, setLoading] = useState(true);
52
+ const [pending, setPending] = useState(false);
53
+ const [error, setError] = useState("");
54
+ const [notice, setNotice] = useState("");
55
+ const [reload, setReload] = useState(0);
56
+ const current = examples.find(example => example.id === selected);
57
+ const dirty = Boolean(draft && (!current || JSON.stringify(draft) !== JSON.stringify(draftOf(current))));
58
+ useEffect(() => { onDirty(dirty); }, [dirty, onDirty]);
59
+ useEffect(() => () => { onDirty(false); onPending(false); }, [onDirty, onPending]);
60
+ useEffect(() => {
61
+ let active = true; setLoading(true); setError("");
62
+ void Promise.all([
63
+ requestJson<{ fixtures: WorkflowFixture[] }>(`/v1/admin/workflows/${workflowId}/fixtures`),
64
+ requestJson<{ test_runs: SavedTest[] }>(`/v1/admin/workflows/${workflowId}/test-runs`),
65
+ ]).then(([fixtures, results]) => {
66
+ if (!active) return;
67
+ setExamples(fixtures.fixtures); setTests(results.test_runs); setLoadedEpoch(epoch);
68
+ setSelected(previous => fixtures.fixtures.some(fixture => fixture.id === previous) ? previous : fixtures.fixtures[0]?.id ?? null);
69
+ }).catch(cause => { if (active) setError(errorMessage(cause, "Examples could not be loaded.")); }).finally(() => { if (active) setLoading(false); });
70
+ return () => { active = false; };
71
+ }, [workflowId, epoch, reload]);
72
+ useEffect(() => { if (current) setDraft(draftOf(current)); }, [current]);
73
+ useEffect(() => {
74
+ if (!loading && loadedEpoch !== seenEpoch.current && resultRef.current) {
75
+ seenEpoch.current = loadedEpoch;
76
+ // Scroll this panel only. scrollIntoView can also scroll the dialog's
77
+ // overflow-hidden box on mobile, moving its toolbar out of reach.
78
+ const panel = resultRef.current.closest<HTMLElement>(".playbook-lifecycle-panel");
79
+ if (panel) panel.scrollTo({ top: panel.scrollTop + resultRef.current.getBoundingClientRect().top - panel.getBoundingClientRect().top });
80
+ resultRef.current.focus({ preventScroll: true });
81
+ }
82
+ }, [loadedEpoch, loading, draft]);
83
+ const blocked = disabled || pending || loading;
84
+ const select = (id: string): void => {
85
+ if (dirty && !window.confirm("Discard the unsaved example changes?")) return;
86
+ setSelected(id); setNotice(""); setError("");
87
+ };
88
+ const create = (copy = false): void => {
89
+ if (dirty && !window.confirm("Discard the unsaved example changes?")) return;
90
+ const starter = graph.playbook ? playbookStarterFixtures(graph.playbook)[0] : undefined;
91
+ const next = copy && draft ? { ...draft, name: `${draft.name.slice(0, 105)} - copy` } : {
92
+ name: "New example", input: pretty(starter?.input ?? {}), componentResults: pretty(starter?.component_results), humanResponses: pretty(starter?.human_responses), expected: pretty(starter?.expected), failureInjection: pretty(starter?.failure_injection),
93
+ };
94
+ setSelected(null); setDraft(next); setNotice(""); setError("");
95
+ };
96
+ const save = async (event: FormEvent): Promise<void> => {
97
+ event.preventDefault(); if (!draft || blocked || readOnly) return;
98
+ setError(""); setNotice("");
99
+ try {
100
+ const payload = { name: draft.name, expected_revision: current?.revision, input: parseObject(draft.input, "Case context"), component_results: parseObject(draft.componentResults, "Evidence responses"), human_responses: parseObject(draft.humanResponses, "Reviewer responses"), expected: parseObject(draft.expected, "Expected result"), failure_injection: parseObject(draft.failureInjection, "Failure scenarios") };
101
+ if (typeof payload.expected.terminal_code !== "string" || !graph.nodes.some(node => node.kind === "end" && node.config.terminal_code === payload.expected.terminal_code)) throw new Error("Choose an expected outcome from this playbook before saving.");
102
+ const fingerprint = JSON.stringify({ workflowId, fixtureId: current?.id, payload });
103
+ if (saveRequest.current?.fingerprint !== fingerprint) saveRequest.current = { fingerprint, key: crypto.randomUUID() };
104
+ setPending(true); onPending(true);
105
+ const result = await requestJson<{ fixtureId: string }>(`/v1/admin/workflows/${workflowId}/fixtures${current ? `/${current.id}` : ""}`, { method: current ? "PATCH" : "POST", headers: { ...mutationHeaders(), "idempotency-key": saveRequest.current.key }, body: JSON.stringify(payload) });
106
+ saveRequest.current = null; setSelected(result.fixtureId); setDraft(null); onDirty(false); setReload(value => value + 1); onChanged();
107
+ setNotice("Example saved. Run the examples to check this revision.");
108
+ } catch (cause) { setError(errorMessage(cause, "The example could not be saved. Your changes are still here.")); }
109
+ finally { setPending(false); onPending(false); }
110
+ };
111
+ const remove = async (): Promise<void> => {
112
+ if (!current || blocked || readOnly || !window.confirm(`Delete “${current.name}” from the example suite? Published test records remain available.`)) return;
113
+ setPending(true); onPending(true); setError("");
114
+ try { await requestJson(`/v1/admin/workflows/${workflowId}/fixtures/${current.id}`, { method: "DELETE", headers: mutationHeaders() }); setDraft(null); onDirty(false); setReload(value => value + 1); onChanged(); setNotice("Example deleted. Review the remaining examples before publication."); }
115
+ catch (cause) { setError(errorMessage(cause, "The example could not be deleted.")); }
116
+ finally { setPending(false); onPending(false); }
117
+ };
118
+ // Structured fields update only their portion; advanced fixtures remain lossless.
119
+ const editObject = (key: keyof Omit<ExampleDraft, "name">, edit: (value: JsonObject) => JsonObject): void => {
120
+ if (!draft) return;
121
+ try { const next = edit(parseObject(draft[key], "Example data")); setDraft({ ...draft, [key]: pretty(next) }); setError(""); }
122
+ catch (cause) { setError(errorMessage(cause, "Fix the advanced data before editing this field.")); }
123
+ };
124
+ const parsed = (key: keyof Omit<ExampleDraft, "name">): JsonObject => { try { return object(JSON.parse(draft?.[key] ?? "{}")); } catch { return {}; } };
125
+ const input = parsed("input"), expected = parsed("expected"), responses = parsed("humanResponses");
126
+ const review = object(responses.review);
127
+ const last = tests.find(result => result.fixtureId === selected);
128
+ const isFresh = (fixture: WorkflowFixture, result?: SavedTest): boolean => Boolean(result && result.graphDigest === graphDigest && result.fixtureRevision === fixture.revision);
129
+ const freshPassed = examples.filter(example => { const result = tests.find(test => test.fixtureId === example.id); return result?.status === "passed" && isFresh(example, result); }).length;
130
+ return <section className="playbook-lifecycle-panel" aria-label="Playbook examples">
131
+ <div className="playbook-panel-heading"><div><p className="eyebrow">Test before activation</p><h3>How should these cases end?</h3><p>Examples simulate the saved workflow and supplied evidence. They do not call customer systems or measure live model judgement.</p></div><button type="button" className="secondary" disabled={blocked || readOnly} onClick={() => create()}>Add example</button></div>
132
+ {loading ? <p role="status">Loading saved examples and results…</p> : <>
133
+ <div className="playbook-suite-summary" role="status"><strong>{freshPassed} of {examples.length} examples passing for this draft</strong><span>{dirty ? "Save or discard this example before running the suite." : "Every saved example must pass before publication."}</span></div>
134
+ <div className="playbook-examples-layout"><nav aria-label="Saved examples" className="playbook-example-list">{examples.map(example => {
135
+ const result = tests.find(test => test.fixtureId === example.id);
136
+ return <button type="button" key={example.id} aria-current={example.id === selected ? "true" : undefined} disabled={blocked} onClick={() => select(example.id)}><strong>{example.name}</strong><span>{isFresh(example, result) ? readable(result!.status) : result ? "Needs a new test" : "Not tested"}</span></button>;
137
+ })}{!examples.length ? <p>No examples yet. Add a normal case, a missing-evidence case and a failure case.</p> : null}</nav>
138
+ {draft ? <div className="playbook-example-editor"><div ref={resultRef} tabIndex={-1} className="playbook-result-focus"><ExampleResult test={last} current={Boolean(current && isFresh(current, last))} graph={graph} /></div><form onSubmit={event => void save(event)}><fieldset className="playbook-guide-fields" disabled={blocked || readOnly}><legend>Edit example</legend>
139
+ <label>Example name<input required maxLength={120} value={draft.name} onChange={event => setDraft({ ...draft, name: event.currentTarget.value })} /></label>
140
+ {graph.playbook ? <>
141
+ <div className="playbook-field-pair"><label>Reported reason<input value={String(object(input.report).reason_code ?? "")} onChange={event => editObject("input", value => ({ ...value, report: { ...object(value.report), reason_code: event.currentTarget.value } }))} /></label><label>Target type<input value={String(object(input.target).type ?? "")} onChange={event => editObject("input", value => ({ ...value, target: { ...object(value.target), type: event.currentTarget.value } }))} /></label></div>
142
+ <label>What was reported?<textarea value={String(object(input.answers).details ?? "")} onChange={event => editObject("input", value => ({ ...value, answers: { ...object(value.answers), details: event.currentTarget.value } }))} /></label>
143
+ {graph.playbook.rollout !== "observe" ? <>
144
+ <label>Reviewer decision<select value={String(review.decision_code ?? "")} onChange={event => editObject("humanResponses", value => ({ ...value, review: { ...object(value.review), decision_code: event.currentTarget.value } }))}><option value="">Choose a decision</option>{graph.playbook.outcomes.decision_codes.map(code => <option key={code} value={code}>{readable(code)}</option>)}</select></label>
145
+ <label>Reviewer rationale<textarea value={String(review.rationale ?? "")} onChange={event => editObject("humanResponses", value => ({ ...value, review: { ...object(value.review), rationale: event.currentTarget.value } }))} /></label>
146
+ <label className="playbook-check"><input type="checkbox" checked={parsed("failureInjection").review === "timeout"} onChange={event => editObject("failureInjection", value => { const next = { ...value }; if (event.currentTarget.checked) next.review = "timeout"; else delete next.review; return next; })} /><span>Reviewer does not respond before the deadline</span></label>
147
+ </> : null}
148
+ </> : null}
149
+ <label>Expected outcome<select required value={String(expected.terminal_code ?? "")} onChange={event => editObject("expected", value => ({ ...value, terminal_code: event.currentTarget.value }))}><option value="">Choose an outcome</option>{graph.nodes.filter(node => node.kind === "end").map(node => <option key={node.id} value={String(node.config.terminal_code ?? node.id)}>{node.name}</option>)}</select></label>
150
+ <fieldset className="playbook-milestones"><legend>Milestones that must never happen</legend>{graph.nodes.filter(node => ["decision", "action", "send_message"].includes(node.kind)).map(node => <label className="playbook-check" key={node.id}><input type="checkbox" checked={Array.isArray(expected.forbidden_nodes) && expected.forbidden_nodes.includes(node.id)} onChange={event => editObject("expected", value => ({ ...value, forbidden_nodes: event.currentTarget.checked ? [...(Array.isArray(value.forbidden_nodes) ? value.forbidden_nodes : []), node.id] : (Array.isArray(value.forbidden_nodes) ? value.forbidden_nodes : []).filter(id => id !== node.id) }))} /><span>{node.name}</span></label>)}</fieldset>
151
+ <details className="playbook-advanced-example"><summary>Evidence responses and advanced data</summary><p>Use representative, de-identified data. Responses are keyed by the step or capability version. These values are simulated, including any investigator response.</p>{([
152
+ ["input", "Case context"], ["componentResults", "Evidence responses"], ["humanResponses", "Reviewer responses"], ["expected", "Expected result"], ["failureInjection", "Failure scenarios"],
153
+ ] as const).map(([key, label]) => <label key={key}>{label}<textarea spellCheck={false} className="code-input" value={draft[key]} onChange={event => setDraft({ ...draft, [key]: event.currentTarget.value })} /></label>)}</details>
154
+ </fieldset><div className="playbook-guide-save"><button type="button" className="text-button" disabled={blocked || readOnly || !current} onClick={() => create(true)}>Duplicate example</button><button type="button" className="danger-button" disabled={blocked || readOnly || !current} onClick={() => void remove()}>Delete example</button><button type="button" className="secondary" disabled={blocked || !dirty} onClick={() => { setDraft(current ? draftOf(current) : null); setError(""); }}>Discard example changes</button><button type="submit" className="primary" disabled={blocked || readOnly || !dirty}>{pending ? "Saving…" : "Save example"}</button></div></form>
155
+ </div> : <div className="playbook-callout"><p>Select an example or add one to describe the expected outcome.</p></div>}</div>
156
+ </>}
157
+ <p className="error" role="alert">{error}{error ? <button className="text-button" type="button" disabled={blocked} onClick={() => { if (!dirty || window.confirm("Discard local changes and reload saved examples?")) setReload(value => value + 1); }}>Reload saved examples</button> : null}</p><p role="status">{notice}</p>
158
+ <div className="playbook-panel-footer"><span>Publication preserves the exact tested draft and example revisions.</span><button type="button" className="primary" disabled={blocked || dirty || readOnly || !examples.length} onClick={onRun}>Run all examples</button></div>
159
+ </section>;
160
+ }