@skyhook-io/radar-app 1.14.0 → 1.14.2

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 (63) hide show
  1. package/package.json +2 -2
  2. package/src/App.tsx +6 -4
  3. package/src/api/client.ts +3 -0
  4. package/src/api/diagnose.ts +31 -1
  5. package/src/components/CloudConnectFlow.tsx +173 -13
  6. package/src/components/CloudFunnelButton.tsx +4 -2
  7. package/src/components/ConnectionErrorView.tsx +9 -10
  8. package/src/components/DebugOverlay.tsx +10 -6
  9. package/src/components/cloudConnectHandoff.test.ts +109 -4
  10. package/src/components/cloudConnectHandoff.ts +154 -2
  11. package/src/components/curl/ServiceCurlButton.tsx +19 -26
  12. package/src/components/diagnose/AgentCase.tsx +18 -5
  13. package/src/components/diagnose/AnalysisStory.test.tsx +308 -0
  14. package/src/components/diagnose/AnalysisStory.tsx +564 -0
  15. package/src/components/diagnose/DiagnoseContext.test.ts +10 -0
  16. package/src/components/diagnose/DiagnoseContext.tsx +25 -8
  17. package/src/components/diagnose/DiagnoseSurface.tsx +20 -14
  18. package/src/components/diagnose/InvestigationEvidencePane.story.test.tsx +771 -0
  19. package/src/components/diagnose/InvestigationEvidencePane.test.tsx +8 -33
  20. package/src/components/diagnose/InvestigationEvidencePane.tsx +809 -168
  21. package/src/components/diagnose/InvestigationResourceEvidence.test.tsx +30 -0
  22. package/src/components/diagnose/InvestigationResourceEvidence.tsx +20 -0
  23. package/src/components/diagnose/InvestigationView.tsx +493 -529
  24. package/src/components/diagnose/investigationCase.test.tsx +267 -129
  25. package/src/components/diagnose/investigationCase.ts +122 -77
  26. package/src/components/diagnose/investigationEvidence/adapters/resource.test.ts +27 -0
  27. package/src/components/diagnose/investigationEvidence/adapters/resource.ts +28 -0
  28. package/src/components/diagnose/investigationEvidence/builder.ts +11 -2
  29. package/src/components/diagnose/investigationEvidence/identity.test.ts +25 -7
  30. package/src/components/diagnose/investigationEvidence/identity.ts +4 -4
  31. package/src/components/diagnose/investigationEvidence/observations.ts +24 -0
  32. package/src/components/diagnose/investigationEvidence/projection.test.ts +36 -3
  33. package/src/components/diagnose/investigationEvidence/types.ts +2 -0
  34. package/src/components/diagnose/investigationEvidencePresentation.test.ts +2 -0
  35. package/src/components/diagnose/investigationEvidencePresentation.ts +3 -0
  36. package/src/components/diagnose/investigationState.test.ts +316 -58
  37. package/src/components/diagnose/investigationState.ts +257 -44
  38. package/src/components/diagnose/investigationStory.test.ts +161 -0
  39. package/src/components/diagnose/investigationStory.ts +207 -0
  40. package/src/components/diagnose/parts.test.tsx +9 -6
  41. package/src/components/diagnose/parts.tsx +977 -193
  42. package/src/components/diagnose/storyParts.test.tsx +426 -0
  43. package/src/components/diagnose/toolCallLabel.test.ts +51 -0
  44. package/src/components/diagnose/toolCallLabel.ts +135 -0
  45. package/src/components/diagnose/useDisclosureReveal.ts +10 -11
  46. package/src/components/helm/HelmCompareRoute.tsx +18 -4
  47. package/src/components/helm/HelmReleaseDrawer.tsx +11 -26
  48. package/src/components/helm/InstallWizard.tsx +8 -3
  49. package/src/components/home/MCPSetupDialog.tsx +15 -9
  50. package/src/components/portforward/PortForwardManager.tsx +6 -13
  51. package/src/components/resource/PrometheusChartsGrid.tsx +3 -3
  52. package/src/components/resource/WorkloadMetricsHelpDialog.tsx +3 -3
  53. package/src/components/resource/WorkloadMetricsSection.tsx +15 -21
  54. package/src/components/resources/PodFilePreview.tsx +3 -3
  55. package/src/components/resources/renderers/ServiceRenderer.tsx +2 -1
  56. package/src/components/settings/SettingsDialog.tsx +4 -2
  57. package/src/components/ui/CommandPalette.tsx +10 -6
  58. package/src/components/ui/DiagnosticsOverlay.tsx +10 -6
  59. package/src/components/ui/Disclosure.tsx +47 -0
  60. package/src/components/ui/Markdown.tsx +23 -11
  61. package/src/components/ui/ShortcutHelpOverlay.tsx +3 -1
  62. package/src/components/ui/UpdateNotification.tsx +18 -2
  63. package/src/index.css +19 -6
@@ -24,11 +24,19 @@ import {
24
24
  Maximize2,
25
25
  HelpCircle,
26
26
  FileSearch,
27
+ Search,
28
+ ListChecks,
27
29
  } from "lucide-react";
28
30
  import { stringify as toYaml } from "yaml";
29
31
  import { codeToHtml } from "shiki";
30
32
  import { DialogPortal } from "@skyhook-io/k8s-ui/components/ui/DialogPortal";
31
33
  import { parseContextName } from "../../utils/context-name";
34
+ import {
35
+ TRANSITION_MENU,
36
+ overlayExitMs,
37
+ overlayTransitionStyle,
38
+ } from "../../utils/animation";
39
+ import { useAnimatedUnmount } from "../../hooks/useAnimatedUnmount";
32
40
  import { useTheme } from "../../context/ThemeContext";
33
41
  import { type DiagnoseConsentCopy } from "../../context/DiagnoseCustomization";
34
42
  import {
@@ -59,6 +67,9 @@ import type {
59
67
  InvestigationCaseResolution,
60
68
  } from "./investigationCase";
61
69
  import { AgentClaimNote } from "./AgentCase";
70
+ import type { InvestigationHealthSignal } from "./investigationState";
71
+ import { Badge } from "@skyhook-io/k8s-ui";
72
+ import { diagnosisHasStoryShape, storyPlainText } from "./investigationStory";
62
73
 
63
74
  import { useDisclosureReveal } from "./useDisclosureReveal";
64
75
 
@@ -192,6 +203,12 @@ function SelectMenu({
192
203
  hint?: string;
193
204
  }) {
194
205
  const [open, setOpen] = useState(false);
206
+ // Presence outlives `open` by the menu exit so the list can fade out; the
207
+ // click-away backdrop only exists while logically open.
208
+ const { shouldRender, isOpen } = useAnimatedUnmount(
209
+ open,
210
+ overlayExitMs("menu"),
211
+ );
195
212
  const current = options.find((o) => o.value === value) ?? options[0];
196
213
  return (
197
214
  <div>
@@ -209,47 +226,50 @@ function SelectMenu({
209
226
  <ChevronDown className="h-3.5 w-3.5 shrink-0 text-theme-text-tertiary" />
210
227
  </button>
211
228
  {open && (
212
- <>
213
- <div
214
- className="fixed inset-0 z-10"
215
- onClick={() => setOpen(false)}
216
- />
217
- <ul
218
- role="listbox"
219
- className="absolute left-0 right-0 z-20 mt-1 max-h-72 overflow-y-auto rounded-md border border-theme-border bg-theme-surface py-1 shadow-theme-lg"
220
- >
221
- {options.map((o) => {
222
- const sel = o.value === value;
223
- return (
224
- <li key={o.value}>
225
- <button
226
- role="option"
227
- aria-selected={sel}
228
- onClick={() => {
229
- onChange(o.value);
230
- setOpen(false);
231
- }}
232
- className="flex w-full items-start gap-2 px-2.5 py-1.5 text-left hover:bg-theme-hover"
233
- >
234
- <Check
235
- className={`mt-0.5 h-3.5 w-3.5 shrink-0 ${sel ? "text-accent" : "opacity-0"}`}
236
- />
237
- <span className="min-w-0">
238
- <span className="block text-xs font-medium text-theme-text-primary">
239
- {o.label}
240
- </span>
241
- {o.description && (
242
- <span className="block text-[11px] leading-snug text-theme-text-tertiary">
243
- {o.description}
244
- </span>
245
- )}
229
+ <div className="fixed inset-0 z-10" onClick={() => setOpen(false)} />
230
+ )}
231
+ {shouldRender && (
232
+ <ul
233
+ role="listbox"
234
+ inert={!open || undefined}
235
+ className={`absolute left-0 right-0 z-20 mt-1 max-h-72 origin-top overflow-y-auto rounded-md border border-theme-border bg-theme-surface py-1 shadow-theme-lg ${TRANSITION_MENU} ${
236
+ isOpen
237
+ ? "opacity-100 translate-y-0 scale-100"
238
+ : "opacity-0 -translate-y-1 scale-[0.97]"
239
+ } ${open ? "" : "pointer-events-none"}`}
240
+ style={overlayTransitionStyle(isOpen, "menu")}
241
+ >
242
+ {options.map((o) => {
243
+ const sel = o.value === value;
244
+ return (
245
+ <li key={o.value}>
246
+ <button
247
+ role="option"
248
+ aria-selected={sel}
249
+ onClick={() => {
250
+ onChange(o.value);
251
+ setOpen(false);
252
+ }}
253
+ className="flex w-full items-start gap-2 px-2.5 py-1.5 text-left hover:bg-theme-hover"
254
+ >
255
+ <Check
256
+ className={`mt-0.5 h-3.5 w-3.5 shrink-0 ${sel ? "text-accent" : "opacity-0"}`}
257
+ />
258
+ <span className="min-w-0">
259
+ <span className="block text-xs font-medium text-theme-text-primary">
260
+ {o.label}
246
261
  </span>
247
- </button>
248
- </li>
249
- );
250
- })}
251
- </ul>
252
- </>
262
+ {o.description && (
263
+ <span className="block text-[11px] leading-snug text-theme-text-tertiary">
264
+ {o.description}
265
+ </span>
266
+ )}
267
+ </span>
268
+ </button>
269
+ </li>
270
+ );
271
+ })}
272
+ </ul>
253
273
  )}
254
274
  </div>
255
275
  {hint && (
@@ -616,6 +636,8 @@ export function TurnView({
616
636
  onCheckStatus,
617
637
  onRetryDiagnosis,
618
638
  hideConclusion = false,
639
+ assessment = false,
640
+ explanation,
619
641
  turnIndex,
620
642
  evidenceStepIds,
621
643
  onViewEvidence,
@@ -630,9 +652,13 @@ export function TurnView({
630
652
  onRetryDiagnosis?: () => void;
631
653
  /** Sources and agent items an answer turn cited; answers otherwise show none. */
632
654
  assessmentSources?: ReactNode;
633
- // In the maximized workspace the pinned turn's conclusion renders in the side rail,
634
- // so the transcript suppresses its own copy (reasoning + tool calls still show).
655
+ // The current assessment is Findings; the transcript keeps a pointer to it
656
+ // rather than a second copy (reasoning + tool calls still show).
635
657
  hideConclusion?: boolean;
658
+ /** This turn is (or was) an assessment: render its full verdict, never as a conversational answer. */
659
+ assessment?: boolean;
660
+ /** A saved plain-language explanation of an earlier assessment, shown with it here. */
661
+ explanation?: AssessmentExplanation;
636
662
  turnIndex?: number;
637
663
  evidenceStepIds?: ReadonlySet<string>;
638
664
  onViewEvidence?: (sourceId: string) => void;
@@ -644,8 +670,9 @@ export function TurnView({
644
670
  }) {
645
671
  // A follow-up (a turn the user asked a question on) is a conversational reply,
646
672
  // not a fresh diagnosis — render it as a plain answer, never the root-cause
647
- // anchor or a remediation card.
648
- const followup = !!turn.question && !turn.apply && !turn.verify;
673
+ // anchor or a remediation card — unless its verdict revised the assessment.
674
+ const followup =
675
+ !!turn.question && !turn.apply && !turn.verify && !assessment;
649
676
  // Whether the done turn has anything for ResultCard to render — mirrors its
650
677
  // branch order exactly (apply → followup → structured/healthy), since a followup
651
678
  // ONLY ever renders FollowupAnswer (report/rootCause), never the remediation list.
@@ -726,7 +753,17 @@ export function TurnView({
726
753
  onCheckStatus={onCheckStatus}
727
754
  animate={turn.animateResult !== false}
728
755
  />
729
- ) : hideConclusion && hasResult ? null : hasResult ? (
756
+ ) : hideConclusion && hasResult ? (
757
+ assessment ? (
758
+ <p
759
+ data-turn-assessment-pointer
760
+ className="flex items-center gap-1.5 text-xs text-theme-text-tertiary"
761
+ >
762
+ <Sparkles className="h-3.5 w-3.5 text-accent" aria-hidden />
763
+ Assessment · shown in Findings
764
+ </p>
765
+ ) : null
766
+ ) : hasResult ? (
730
767
  <ResultCard
731
768
  diagnosis={turn.diagnosis!}
732
769
  onApply={onApply}
@@ -734,10 +771,19 @@ export function TurnView({
734
771
  onCheckStatus={onCheckStatus}
735
772
  animate={turn.animateResult !== false}
736
773
  assessmentSources={assessmentSources}
774
+ explanation={explanation}
775
+ storyInline={assessment}
776
+ readOnlyAssessment={assessment}
737
777
  />
738
778
  ) : (
739
779
  <EmptyResult animate={turn.animateResult !== false} />
740
780
  ))}
781
+ {!turn.explainAssessment &&
782
+ !turn.apply &&
783
+ turn.status === "done" &&
784
+ turn.diagnosis?.notes ? (
785
+ <WorkingNotes notes={turn.diagnosis.notes} />
786
+ ) : null}
741
787
  {turn.explainAssessment ? null : turn.status === "error" && turn.apply ? (
742
788
  <ApplyOutcomeCard
743
789
  diagnosis={turn.diagnosis}
@@ -1002,6 +1048,8 @@ export function ApplyDialog({
1002
1048
  resourceLabel,
1003
1049
  context,
1004
1050
  fix,
1051
+ reason,
1052
+ precondition,
1005
1053
  managedBy,
1006
1054
  confidence,
1007
1055
  }: {
@@ -1012,6 +1060,10 @@ export function ApplyDialog({
1012
1060
  resourceLabel: string;
1013
1061
  context: string;
1014
1062
  fix?: string;
1063
+ /** The agent's one-clause case for this step, repeated at the decision. */
1064
+ reason?: string;
1065
+ /** The condition the agent attached to this step; shown before the operator confirms. */
1066
+ precondition?: string;
1015
1067
  managedBy?: string; // GitOps/Helm owner of the resource, if any
1016
1068
  confidence?: number;
1017
1069
  }) {
@@ -1073,6 +1125,31 @@ export function ApplyDialog({
1073
1125
  <AIMarkdown className="text-sm text-theme-text-primary [overflow-wrap:anywhere] [&_code]:font-normal [&_p]:my-0 [&_p]:text-theme-text-primary [&_pre]:my-1.5 [&_pre]:whitespace-pre-wrap [&_pre_code]:whitespace-pre-wrap">
1074
1126
  {fixText}
1075
1127
  </AIMarkdown>
1128
+ {reason ? (
1129
+ <p
1130
+ data-apply-reason
1131
+ className="mt-2 text-sm text-theme-text-secondary"
1132
+ >
1133
+ <span className="font-medium text-theme-text-primary">
1134
+ Why this step:
1135
+ </span>{" "}
1136
+ {reason}
1137
+ </p>
1138
+ ) : null}
1139
+ {precondition ? (
1140
+ <p
1141
+ data-apply-precondition
1142
+ className="mt-2 flex items-start gap-1.5 rounded-md border border-amber-500/30 bg-amber-500/5 px-2.5 py-1.5 text-xs text-theme-text-primary"
1143
+ >
1144
+ <AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-amber-500" />
1145
+ <span>
1146
+ <span className="font-medium">
1147
+ The agent said this applies only if:
1148
+ </span>{" "}
1149
+ {precondition}. Radar has not checked that condition.
1150
+ </span>
1151
+ </p>
1152
+ ) : null}
1076
1153
  </div>
1077
1154
  )}
1078
1155
  </div>
@@ -1498,16 +1575,19 @@ function ToolRow({
1498
1575
  {prettyTool(step.tool)}
1499
1576
  </span>
1500
1577
  {step.summary && !open && (
1501
- <span className="min-w-0 flex-1 truncate font-mono text-[11px] text-theme-text-tertiary">
1578
+ <span className="min-w-0 flex-1 shrink-[4] truncate font-mono text-[11px] text-theme-text-tertiary">
1502
1579
  {argumentsPreview}
1503
1580
  </span>
1504
1581
  )}
1505
1582
  {errorReason && !open && (
1506
1583
  // The arguments give way first: they are still readable expanded,
1507
- // while the reason is the one thing this row exists to say.
1508
- <span className="investigation-tool-reason max-w-full shrink-0 truncate text-[11px] text-semantic-error">
1509
- {middleTruncate(errorReason)}
1510
- </span>
1584
+ // while the reason is the one thing this row exists to say. It still
1585
+ // clips at the row's edge; the full text is a hover and a click away.
1586
+ <Tooltip content={errorReason} wrapperClassName="min-w-0 truncate">
1587
+ <span className="investigation-tool-reason block min-w-0 truncate text-[11px] text-semantic-error">
1588
+ {middleTruncate(errorReason)}
1589
+ </span>
1590
+ </Tooltip>
1511
1591
  )}
1512
1592
  {durationLabel && (
1513
1593
  <span className="ml-auto shrink-0 text-[11px] text-theme-text-tertiary">
@@ -1927,6 +2007,13 @@ export function ResultCard({
1927
2007
  assessmentAction,
1928
2008
  assessmentSources,
1929
2009
  actionNotice,
2010
+ storyInline = false,
2011
+ readOnlyAssessment = false,
2012
+ revisedAfter,
2013
+ assessmentLimits,
2014
+ assessmentCopy,
2015
+ healthSignals,
2016
+ onRevealSource,
1930
2017
  }: {
1931
2018
  diagnosis: Diagnosis;
1932
2019
  onApply?: (fix: string) => void;
@@ -1935,6 +2022,21 @@ export function ResultCard({
1935
2022
  applyOutcome?: ApplyMutationOutcome;
1936
2023
  followup?: boolean;
1937
2024
  section?: "full" | "conclusion" | "actions";
2025
+ /**
2026
+ * Render the story as plain prose inside this card. Findings renders the
2027
+ * story itself, with placed cards, so it leaves this off; Activity's
2028
+ * read-only copies of earlier assessments turn it on.
2029
+ */
2030
+ storyInline?: boolean;
2031
+ /** An earlier assessment shown for the record: no Apply, labelled as such. */
2032
+ readOnlyAssessment?: boolean;
2033
+ /** The question that produced this revised assessment, when it replaced an earlier one. */
2034
+ revisedAfter?: string;
2035
+ /** Reads Radar could not complete for this assessment; listed under Still open. */
2036
+ assessmentLimits?: string[];
2037
+ assessmentCopy?: Pick<AssessmentCopyRadar, "context" | "receipts">;
2038
+ healthSignals?: InvestigationHealthSignal[];
2039
+ onRevealSource?: (sourceId: string) => void;
1938
2040
  onCheckStatus?: () => void;
1939
2041
  animate?: boolean;
1940
2042
  /** Additional navigation placed in the assessment action row. */
@@ -1990,14 +2092,53 @@ export function ResultCard({
1990
2092
  evidenceConflictExplainedBy={evidenceConflictExplainedBy}
1991
2093
  assessmentAction={assessmentAction}
1992
2094
  assessmentSources={assessmentSources}
2095
+ storyInline={storyInline}
2096
+ revisedAfter={revisedAfter}
2097
+ assessmentLimits={assessmentLimits}
2098
+ assessmentCopy={assessmentCopy}
2099
+ healthSignals={healthSignals}
2100
+ onRevealSource={onRevealSource}
1993
2101
  />
1994
2102
  );
1995
2103
  // Couldn't-determine is its own honest state — never a confident all-clear, never
1996
- // the alarming root-cause anchor.
1997
- if (diagnosis.inconclusive && !diagnosis.rootCause)
1998
- return section === "actions" ? null : (
2104
+ // the alarming root-cause anchor. With typed steps it can still carry the
2105
+ // next check, which the actions section renders like any other step.
2106
+ if (diagnosis.inconclusive && !diagnosis.rootCause) {
2107
+ if (section === "actions")
2108
+ return (diagnosis.steps?.length ?? 0) > 0 ? (
2109
+ <DiagnosisResult
2110
+ diagnosis={diagnosis}
2111
+ onApply={readOnlyAssessment ? undefined : onApply}
2112
+ section="actions"
2113
+ animate={animate}
2114
+ showDisclaimer={false}
2115
+ compactActions={compactActions}
2116
+ actionNotice={actionNotice}
2117
+ readOnlyAssessment={readOnlyAssessment}
2118
+ />
2119
+ ) : null;
2120
+ return (
1999
2121
  <>
2000
- <InconclusiveCard diagnosis={diagnosis} animate={animate} />
2122
+ <InconclusiveCard
2123
+ diagnosis={diagnosis}
2124
+ animate={animate}
2125
+ storyInline={storyInline}
2126
+ revisedAfter={revisedAfter}
2127
+ assessmentLimits={assessmentLimits}
2128
+ assessmentCopy={assessmentCopy}
2129
+ />
2130
+ {section === "full" && (diagnosis.steps?.length ?? 0) > 0 ? (
2131
+ <DiagnosisResult
2132
+ diagnosis={diagnosis}
2133
+ onApply={readOnlyAssessment ? undefined : onApply}
2134
+ section="actions"
2135
+ animate={false}
2136
+ showDisclaimer={false}
2137
+ compactActions={compactActions}
2138
+ actionNotice={actionNotice}
2139
+ readOnlyAssessment={readOnlyAssessment}
2140
+ />
2141
+ ) : null}
2001
2142
  {assessmentSources ? (
2002
2143
  <AssessmentSourceDetails>{assessmentSources}</AssessmentSourceDetails>
2003
2144
  ) : null}
@@ -2006,6 +2147,7 @@ export function ResultCard({
2006
2147
  )}
2007
2148
  </>
2008
2149
  );
2150
+ }
2009
2151
  // A turn with no structured root cause and no remediation (e.g. "looks healthy",
2010
2152
  // or a clarifying question) is not a diagnosis — render it neutrally rather than
2011
2153
  // forcing the alarming root-cause anchor onto a non-problem.
@@ -2027,7 +2169,7 @@ export function ResultCard({
2027
2169
  return (
2028
2170
  <DiagnosisResult
2029
2171
  diagnosis={diagnosis}
2030
- onApply={onApply}
2172
+ onApply={readOnlyAssessment ? undefined : onApply}
2031
2173
  explanation={explanation}
2032
2174
  section={section}
2033
2175
  animate={animate}
@@ -2036,10 +2178,341 @@ export function ResultCard({
2036
2178
  assessmentAction={assessmentAction}
2037
2179
  assessmentSources={assessmentSources}
2038
2180
  actionNotice={actionNotice}
2181
+ storyInline={storyInline}
2182
+ readOnlyAssessment={readOnlyAssessment}
2183
+ revisedAfter={revisedAfter}
2184
+ assessmentLimits={assessmentLimits}
2185
+ assessmentCopy={assessmentCopy}
2039
2186
  />
2040
2187
  );
2041
2188
  }
2042
2189
 
2190
+ /**
2191
+ * The whole assessment as text: headline, certainty, what is unresolved, the
2192
+ * technical cause, the story without its markers, and the steps. Copying only
2193
+ * the headline would ship the persuasive half without its caveats.
2194
+ */
2195
+ /** What Radar adds to the agent's verdict when the assessment is copied for a channel. */
2196
+ interface AssessmentCopyRadar {
2197
+ /** Reads Radar could not complete, as shown in Still open. */
2198
+ limits?: readonly string[];
2199
+ /** Adverse Radar cards the agent explained, as shown in Still open. */
2200
+ signals?: readonly string[];
2201
+ /** Adverse Radar cards nothing explains, as shown above a healthy verdict. */
2202
+ flags?: readonly string[];
2203
+ /** Where and when: the header line a reader in a channel needs first. */
2204
+ context?: {
2205
+ target: string;
2206
+ cluster?: string;
2207
+ startedAt?: string;
2208
+ agent?: string;
2209
+ };
2210
+ /** The placed cards, as short excerpts: the receipts behind the headline. */
2211
+ receipts?: readonly {
2212
+ title: string;
2213
+ role?: string;
2214
+ lines: readonly string[];
2215
+ /** The agent's statement of what this result does not cover. */
2216
+ gap?: string;
2217
+ }[];
2218
+ }
2219
+
2220
+ /**
2221
+ * The assessment as Markdown for a channel or a ticket: context, headline,
2222
+ * cause, the receipts, what is still open, the next step. The full story and
2223
+ * every captured result stay in Radar; the text says so.
2224
+ */
2225
+ export function assessmentCopyText(
2226
+ diagnosis: Diagnosis,
2227
+ radar: AssessmentCopyRadar = {},
2228
+ ): string {
2229
+ const parts: string[] = [];
2230
+ if (radar.context) {
2231
+ const when = radar.context.startedAt
2232
+ ? new Date(radar.context.startedAt).toLocaleString(undefined, {
2233
+ dateStyle: "medium",
2234
+ timeStyle: "short",
2235
+ })
2236
+ : undefined;
2237
+ parts.push(
2238
+ [
2239
+ `**${radar.context.target}**`,
2240
+ radar.context.cluster,
2241
+ when,
2242
+ radar.context.agent ? `via ${radar.context.agent}` : undefined,
2243
+ ]
2244
+ .filter(Boolean)
2245
+ .join(" · "),
2246
+ );
2247
+ }
2248
+ if (diagnosis.summary) {
2249
+ const certainty = diagnosis.healthy
2250
+ ? "Healthy"
2251
+ : diagnosis.certainty
2252
+ ? CERTAINTY_LABEL[diagnosis.certainty]
2253
+ : undefined;
2254
+ parts.push(
2255
+ `**${diagnosis.summary.trim()}**${certainty ? ` — ${certainty} (agent's word)` : ""}`,
2256
+ );
2257
+ } else if (diagnosis.certainty) {
2258
+ parts.push(`Certainty (agent): ${CERTAINTY_LABEL[diagnosis.certainty]}`);
2259
+ }
2260
+ // The pasted text must not read more confident than the screen: Radar's
2261
+ // own qualifications travel with the agent's.
2262
+ const flags = (radar.flags ?? []).filter((item) => item.trim());
2263
+ if (flags.length > 0)
2264
+ parts.push(
2265
+ [
2266
+ "Radar flagged:",
2267
+ ...flags.map((item) => `- ${item}`),
2268
+ "Read those cards before treating this as an all-clear.",
2269
+ ].join("\n"),
2270
+ );
2271
+ const unresolved = [
2272
+ ...(diagnosis.unresolved ?? []),
2273
+ ...(radar.limits ?? []),
2274
+ ...(radar.signals ?? []),
2275
+ ].filter((item) => item.trim());
2276
+ if (unresolved.length > 0)
2277
+ parts.push(
2278
+ ["Still open:", ...unresolved.map((item) => `- ${item}`)].join("\n"),
2279
+ );
2280
+ if (diagnosis.rootCause) parts.push(storyPlainText(diagnosis.rootCause));
2281
+ const receipts = (radar.receipts ?? []).filter((receipt) => receipt.title);
2282
+ if (receipts.length > 0) {
2283
+ parts.push(
2284
+ [
2285
+ "Evidence (Radar):",
2286
+ ...receipts.map((receipt) =>
2287
+ [
2288
+ `- **${receipt.title}**${receipt.role ? ` · ${receipt.role}` : ""}`,
2289
+ ...receipt.lines
2290
+ .filter((line) => line.trim())
2291
+ .map((line) => ` > ${line.trim()}`),
2292
+ ...(receipt.gap ? [` Not shown: ${receipt.gap}`] : []),
2293
+ ].join("\n"),
2294
+ ),
2295
+ ].join("\n"),
2296
+ );
2297
+ } else if (!radar.context) {
2298
+ const story = storyPlainText(diagnosis.report ?? "");
2299
+ if (story) parts.push(story);
2300
+ }
2301
+ const steps = diagnosis.steps?.length
2302
+ ? diagnosis.steps.map(
2303
+ (step, index) =>
2304
+ `${index + 1}. [${STEP_KIND_LABEL[step.kind]}] ${step.text}${step.precondition ? ` (only if ${step.precondition})` : ""}`,
2305
+ )
2306
+ : (diagnosis.remediation ?? []).map(
2307
+ (text, index) => `${index + 1}. ${text}`,
2308
+ );
2309
+ if (steps.length > 0) {
2310
+ if (radar.context) {
2311
+ // A channel gets the recommended step; the rest wait in Radar.
2312
+ const lead = diagnosis.recommendedIndex
2313
+ ? diagnosis.recommendedIndex - 1
2314
+ : 0;
2315
+ const first = steps[lead] ?? steps[0];
2316
+ const rest = steps.length - 1;
2317
+ parts.push(
2318
+ [
2319
+ `Next step: ${first.replace(/^\d+\. /, "")}`,
2320
+ diagnosis.recommendedReason
2321
+ ? `Why: ${diagnosis.recommendedReason}`
2322
+ : undefined,
2323
+ rest > 0
2324
+ ? `${rest} more ${rest === 1 ? "step" : "steps"} in Radar.`
2325
+ : undefined,
2326
+ ]
2327
+ .filter(Boolean)
2328
+ .join("\n"),
2329
+ );
2330
+ } else parts.push(["Next steps:", ...steps].join("\n"));
2331
+ }
2332
+ if (radar.context)
2333
+ parts.push(
2334
+ "Full analysis and every captured result: open the investigation in Radar.",
2335
+ );
2336
+ return parts.join("\n\n");
2337
+ }
2338
+
2339
+ const CERTAINTY_LABEL: Record<NonNullable<Diagnosis["certainty"]>, string> = {
2340
+ established: "Established",
2341
+ likely: "Likely",
2342
+ suspected: "Suspected",
2343
+ };
2344
+
2345
+ // The word is the agent's; the clause says what kind of Radar evidence sits
2346
+ // behind it, so a reader knows what the word licenses them to do.
2347
+ const CERTAINTY_DEFINITION: Record<
2348
+ NonNullable<Diagnosis["certainty"]>,
2349
+ string
2350
+ > = {
2351
+ established:
2352
+ "The agent's word. It found the cause stated outright in Radar's results — a log line, an event, a condition — not just consistent with them.",
2353
+ likely:
2354
+ "The agent's word. Radar's results fit this explanation, but none of them states the cause outright.",
2355
+ suspected:
2356
+ "The agent's word. A plausible reading on thin or indirect evidence; treat it as a lead, not a finding.",
2357
+ };
2358
+ const HEALTHY_DEFINITION: Record<
2359
+ NonNullable<Diagnosis["certainty"]>,
2360
+ string
2361
+ > = {
2362
+ established:
2363
+ "The agent found no live problem, and Radar's results show that directly — the pod state, logs and events it read.",
2364
+ likely:
2365
+ "The agent found no live problem; Radar's results fit that reading but do not show it outright.",
2366
+ suspected:
2367
+ "The agent leans healthy on thin or indirect evidence; treat it as a lead, not a finding.",
2368
+ };
2369
+
2370
+ const STEP_KIND_LABEL: Record<
2371
+ NonNullable<Diagnosis["steps"]>[number]["kind"],
2372
+ string
2373
+ > = {
2374
+ mitigate: "Mitigate",
2375
+ verify: "Verify",
2376
+ investigate: "Investigate",
2377
+ };
2378
+
2379
+ /**
2380
+ * The headline of an assessment that carries the story contract: the
2381
+ * agent's plain-language summary with its own word for how sure it is, the
2382
+ * technical cause beneath it, and what is still unresolved — which renders
2383
+ * whenever the agent listed anything, and says so when it listed nothing,
2384
+ * because a persuasive story needs its caveats above the fold, not inside it.
2385
+ */
2386
+ function AssessmentHeadline({
2387
+ diagnosis,
2388
+ tone,
2389
+ revisedAfter,
2390
+ limits = [],
2391
+ signals,
2392
+ flags,
2393
+ copy,
2394
+ }: {
2395
+ diagnosis: Diagnosis;
2396
+ tone: "cause" | "healthy" | "inconclusive";
2397
+ revisedAfter?: string;
2398
+ /** Reads Radar could not complete for this assessment, one line each. */
2399
+ limits?: string[];
2400
+ /** Adverse Radar cards the agent explained, with its position; first in Still open. */
2401
+ signals?: { text: string; onReveal?: () => void }[];
2402
+ /** Adverse Radar cards nothing explains; shown above a healthy verdict, copied with it. */
2403
+ flags?: string[];
2404
+ /** Context and receipts for the channel copy. */
2405
+ copy?: Pick<AssessmentCopyRadar, "context" | "receipts">;
2406
+ }) {
2407
+ const summary = diagnosis.summary?.trim();
2408
+ if (!summary) return null;
2409
+ // One block for everything that qualifies the answer: what the agent left
2410
+ // open and what Radar could not read. The rest of the page states the
2411
+ // answer; this is the only place it argues with itself.
2412
+ const unresolved = [
2413
+ ...(diagnosis.unresolved ?? []).filter((item) => item.trim()),
2414
+ ...limits,
2415
+ ];
2416
+ const stillOpen = (signals ?? []).length > 0 || unresolved.length > 0;
2417
+ return (
2418
+ <div data-assessment-headline className="space-y-2">
2419
+ <div className="flex items-start justify-between gap-2">
2420
+ <p className="text-[15px] font-semibold leading-snug text-theme-text-primary [overflow-wrap:anywhere] [text-wrap:balance]">
2421
+ {summary}
2422
+ </p>
2423
+ <CopyButton
2424
+ text={assessmentCopyText(diagnosis, {
2425
+ limits,
2426
+ signals: (signals ?? []).map((signal) => signal.text),
2427
+ flags,
2428
+ ...copy,
2429
+ })}
2430
+ label="Copy for a channel"
2431
+ />
2432
+ </div>
2433
+ <div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-theme-text-secondary">
2434
+ {diagnosis.certainty ? (
2435
+ <Tooltip
2436
+ content={
2437
+ tone === "healthy"
2438
+ ? HEALTHY_DEFINITION[diagnosis.certainty]
2439
+ : CERTAINTY_DEFINITION[diagnosis.certainty]
2440
+ }
2441
+ >
2442
+ <Badge tone="agent" size="sm">
2443
+ <Sparkles className="h-2.5 w-2.5 shrink-0" aria-hidden />
2444
+ {tone === "healthy"
2445
+ ? "Healthy"
2446
+ : CERTAINTY_LABEL[diagnosis.certainty]}
2447
+ </Badge>
2448
+ </Tooltip>
2449
+ ) : null}
2450
+ {revisedAfter ? (
2451
+ <span className="min-w-0 text-theme-text-tertiary">
2452
+ Revised after: &ldquo;
2453
+ {revisedAfter.length > 110
2454
+ ? `${revisedAfter.slice(0, 110).trimEnd()}…`
2455
+ : revisedAfter}
2456
+ &rdquo;
2457
+ </span>
2458
+ ) : null}
2459
+ </div>
2460
+ {tone === "cause" && diagnosis.rootCause ? (
2461
+ <AIMarkdown className="text-[13px] leading-relaxed text-theme-text-secondary [overflow-wrap:anywhere] [&_code]:font-normal [&_p]:my-0 [&_p]:text-theme-text-secondary">
2462
+ {diagnosis.rootCause}
2463
+ </AIMarkdown>
2464
+ ) : null}
2465
+ {stillOpen ? (
2466
+ <div
2467
+ data-assessment-unresolved
2468
+ className="rounded-md border border-theme-border bg-theme-base/40 px-2.5 py-2"
2469
+ >
2470
+ <div className="mb-1 flex items-center gap-1.5 text-[11px] font-semibold text-theme-text-secondary">
2471
+ <HelpCircle className="h-3 w-3" aria-hidden />
2472
+ {tone === "inconclusive"
2473
+ ? "What blocked a conclusion"
2474
+ : "Still open"}
2475
+ </div>
2476
+ <ul className="space-y-0.5 text-xs text-theme-text-primary">
2477
+ {unresolved.map((item, index) => (
2478
+ <li key={`open-${index}`} className="flex gap-1.5">
2479
+ <span aria-hidden className="text-theme-text-tertiary">
2480
+ –
2481
+ </span>
2482
+ <span className="[overflow-wrap:anywhere]">{item}</span>
2483
+ </li>
2484
+ ))}
2485
+ {(signals ?? []).map((signal, index) => (
2486
+ <li
2487
+ key={`signal-${index}`}
2488
+ className="flex gap-1.5"
2489
+ data-health-signal
2490
+ >
2491
+ <span aria-hidden className="text-theme-text-tertiary">
2492
+ –
2493
+ </span>
2494
+ {signal.onReveal ? (
2495
+ <button
2496
+ type="button"
2497
+ onClick={signal.onReveal}
2498
+ className="text-left [overflow-wrap:anywhere] hover:text-accent-text"
2499
+ >
2500
+ {signal.text}
2501
+ </button>
2502
+ ) : (
2503
+ <span className="[overflow-wrap:anywhere]">
2504
+ {signal.text}
2505
+ </span>
2506
+ )}
2507
+ </li>
2508
+ ))}
2509
+ </ul>
2510
+ </div>
2511
+ ) : null}
2512
+ </div>
2513
+ );
2514
+ }
2515
+
2043
2516
  /**
2044
2517
  * Sources the assessment cites: root-cause links first, then every source an
2045
2518
  * agent item cites. Each row shows the roles the agent gave that source; a
@@ -2159,11 +2632,11 @@ export function AssessmentSources({
2159
2632
  </div>
2160
2633
  <button
2161
2634
  type="button"
2162
- aria-label={`View ${prettyTool(source.tool)} source used for this assessment`}
2635
+ aria-label={`View ${prettyTool(source.tool)} result used for this assessment`}
2163
2636
  onClick={() => onViewSource(source.id)}
2164
2637
  className="inline-flex shrink-0 items-center gap-1 rounded-md px-1.5 py-0.5 text-xs text-accent-text hover:bg-theme-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/50"
2165
2638
  >
2166
- View source
2639
+ View result
2167
2640
  </button>
2168
2641
  </div>
2169
2642
  {notes.length > 0 ? (
@@ -2211,6 +2684,36 @@ export function AssessmentSources({
2211
2684
  );
2212
2685
  }
2213
2686
 
2687
+ function WorkingNotes({ notes }: { notes: string }) {
2688
+ const [open, setOpen] = useState(false);
2689
+ const id = useId();
2690
+ const reveal = useDisclosureReveal<HTMLDivElement>();
2691
+ return (
2692
+ <div ref={reveal.elementRef} data-turn-working-notes>
2693
+ <button
2694
+ type="button"
2695
+ aria-expanded={open}
2696
+ aria-controls={id}
2697
+ onClick={() => {
2698
+ setOpen(!open);
2699
+ reveal.revealAfterToggle(!open);
2700
+ }}
2701
+ className="flex items-center gap-1.5 rounded-md py-1 text-xs font-medium text-theme-text-secondary hover:text-theme-text-primary"
2702
+ >
2703
+ <CollapseChevron open={open} className="h-3.5 w-3.5" />
2704
+ Evidence considered
2705
+ </button>
2706
+ <div id={id}>
2707
+ <Collapse open={open}>
2708
+ <AIMarkdown className="py-0.5 text-xs leading-relaxed text-theme-text-tertiary [overflow-wrap:anywhere] [&_li]:text-theme-text-tertiary [&_p]:my-0.5 [&_strong]:font-medium [&_strong]:text-theme-text-secondary">
2709
+ {notes}
2710
+ </AIMarkdown>
2711
+ </Collapse>
2712
+ </div>
2713
+ </div>
2714
+ );
2715
+ }
2716
+
2214
2717
  function AssessmentSourceDetails({ children }: { children: ReactNode }) {
2215
2718
  const [open, setOpen] = useState(false);
2216
2719
  const id = useId();
@@ -2280,6 +2783,11 @@ function DiagnosisResult({
2280
2783
  assessmentAction,
2281
2784
  assessmentSources,
2282
2785
  actionNotice,
2786
+ storyInline = false,
2787
+ readOnlyAssessment = false,
2788
+ revisedAfter,
2789
+ assessmentLimits,
2790
+ assessmentCopy,
2283
2791
  }: {
2284
2792
  diagnosis: Diagnosis;
2285
2793
  onApply?: (fix: string) => void;
@@ -2291,7 +2799,21 @@ function DiagnosisResult({
2291
2799
  assessmentAction?: ReactNode;
2292
2800
  assessmentSources?: ReactNode;
2293
2801
  actionNotice?: string;
2802
+ storyInline?: boolean;
2803
+ readOnlyAssessment?: boolean;
2804
+ revisedAfter?: string;
2805
+ /** Reads Radar could not complete for this assessment; listed under Still open. */
2806
+ assessmentLimits?: string[];
2807
+ assessmentCopy?: Pick<AssessmentCopyRadar, "context" | "receipts">;
2294
2808
  }) {
2809
+ // The story contract: a summary headline above, the story rendered by the
2810
+ // host (or inline as plain prose), typed steps below.
2811
+ const storyShape = diagnosisHasStoryShape(diagnosis);
2812
+ const analysisText =
2813
+ storyShape && storyInline
2814
+ ? storyPlainText(diagnosis.report)
2815
+ : diagnosis.report;
2816
+ const showAnalysisDisclosure = !storyShape || storyInline;
2295
2817
  const [detail, setDetail] = useState<"analysis" | "explanation" | null>(
2296
2818
  explanation?.status === "running" ? "explanation" : null,
2297
2819
  );
@@ -2327,10 +2849,15 @@ function DiagnosisResult({
2327
2849
  // "Full analysis" (never relabel the report as a causal assessment).
2328
2850
  const rootCause = diagnosis.rootCause;
2329
2851
  const remediation = diagnosis.remediation || [];
2852
+ const steps = diagnosis.steps ?? [];
2853
+ const typedSteps = steps.length === remediation.length && steps.length > 0;
2330
2854
  const hasRemediation = remediation.length > 0;
2331
2855
  const recIdx = diagnosis.recommendedIndex;
2332
2856
  const recValid =
2333
- recIdx != null && recIdx >= 1 && recIdx <= remediation.length;
2857
+ recIdx != null &&
2858
+ recIdx >= 1 &&
2859
+ recIdx <= remediation.length &&
2860
+ (!typedSteps || steps[recIdx - 1].kind === "mitigate");
2334
2861
  // Apply is offered ONLY when the agent pointed at a safe step (recommended_index).
2335
2862
  // When it returns 0 / none ("needs human judgement"), we honor that and don't
2336
2863
  // offer one-click apply — the steps stay copy-only with a note.
@@ -2351,9 +2878,11 @@ function DiagnosisResult({
2351
2878
  index: number;
2352
2879
  }) => {
2353
2880
  const isRec = recValid && i === recIdx! - 1;
2881
+ const step = typedSteps ? steps[i] : undefined;
2354
2882
  return (
2355
2883
  <div
2356
2884
  key={i}
2885
+ data-step-kind={step?.kind}
2357
2886
  className={
2358
2887
  isRec ? "rounded-lg border border-accent/40 bg-accent/5 p-2.5" : ""
2359
2888
  }
@@ -2366,64 +2895,113 @@ function DiagnosisResult({
2366
2895
  : "bg-theme-base text-theme-text-tertiary"
2367
2896
  }`}
2368
2897
  >
2369
- {i + 1}
2898
+ {step ? (
2899
+ step.kind === "mitigate" ? (
2900
+ <Wrench className="h-2.5 w-2.5" aria-hidden />
2901
+ ) : step.kind === "verify" ? (
2902
+ <ListChecks className="h-2.5 w-2.5" aria-hidden />
2903
+ ) : (
2904
+ <Search className="h-2.5 w-2.5" aria-hidden />
2905
+ )
2906
+ ) : (
2907
+ i + 1
2908
+ )}
2370
2909
  </span>
2371
2910
  <div className="min-w-0 flex-1">
2372
- {isRec && (
2373
- <div className="mb-1">
2374
- <div className="flex items-center gap-1 text-[10px] font-semibold uppercase tracking-wide text-accent">
2375
- <Sparkles className="h-3 w-3" />
2376
- Recommended
2377
- </div>
2378
- {diagnosis.recommendedReason && (
2379
- <div className="mt-0.5 max-w-[100ch] text-[11px] leading-snug text-theme-text-tertiary">
2380
- {diagnosis.recommendedReason}
2381
- </div>
2911
+ {/* Labels, then the action cluster pushed to the row's end, then
2912
+ the reason on its own line — so the step text below spans the
2913
+ card instead of wrapping beside the buttons. */}
2914
+ <div className="mb-1 flex flex-wrap items-center gap-x-2 gap-y-0.5">
2915
+ {(isRec || step) && (
2916
+ <>
2917
+ {step ? (
2918
+ <span className="text-[10px] font-semibold uppercase tracking-wide text-theme-text-tertiary">
2919
+ {STEP_KIND_LABEL[step.kind]}
2920
+ </span>
2921
+ ) : null}
2922
+ {isRec && (
2923
+ <span className="flex items-center gap-1 text-[10px] font-semibold uppercase tracking-wide text-accent">
2924
+ <Sparkles className="h-3 w-3" />
2925
+ Recommended
2926
+ </span>
2927
+ )}
2928
+ </>
2929
+ )}
2930
+ <div className="ml-auto flex shrink-0 items-center gap-0.5">
2931
+ {canApply && isRec && (
2932
+ <button
2933
+ onClick={() => onApply!(r)}
2934
+ className="inline-flex items-center gap-1 rounded-md border border-accent/40 bg-accent/10 px-2 py-1 text-xs font-medium text-accent transition-colors hover:bg-accent/20"
2935
+ >
2936
+ <Wrench className="h-3 w-3" />
2937
+ Apply…
2938
+ </button>
2382
2939
  )}
2940
+ {remediationCommands(r).map((command, c, all) => (
2941
+ <CopyButton
2942
+ key={c}
2943
+ text={command}
2944
+ label={
2945
+ all.length > 1
2946
+ ? `Copy command ${c + 1} of step ${i + 1}`
2947
+ : `Copy command from step ${i + 1}`
2948
+ }
2949
+ />
2950
+ ))}
2383
2951
  </div>
2952
+ </div>
2953
+ {/* The condition and the reason are read before the command is
2954
+ copied, so they precede it at a size that is meant to be read. */}
2955
+ {step?.precondition ? (
2956
+ <p
2957
+ data-step-precondition
2958
+ className="mb-1.5 text-[13px] leading-snug text-warning-text"
2959
+ >
2960
+ Only if {step.precondition.replace(/^(if|when|once)\s+/i, "")}
2961
+ </p>
2962
+ ) : null}
2963
+ {isRec && diagnosis.recommendedReason && (
2964
+ <p
2965
+ data-recommended-reason
2966
+ className="mb-1.5 text-[13px] leading-snug text-theme-text-secondary"
2967
+ >
2968
+ {diagnosis.recommendedReason}
2969
+ </p>
2384
2970
  )}
2385
2971
  <AIMarkdown className="max-w-[100ch] text-sm [overflow-wrap:anywhere] [&_p]:my-0 [&_pre]:my-1.5">
2386
2972
  {r}
2387
2973
  </AIMarkdown>
2388
2974
  </div>
2389
- {/* Action cluster: compact Apply (recommended = subtly
2390
- filled, others = ghost) sits next to Copy so each row's
2391
- actions stay together. The ellipsis signals a confirm
2392
- dialog follows — it doesn't apply immediately. */}
2393
- <div className="flex shrink-0 items-center gap-0.5">
2394
- {canApply && isRec && (
2395
- <button
2396
- onClick={() => onApply!(r)}
2397
- className={`inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium text-accent transition-colors ${
2398
- isRec
2399
- ? "border border-accent/40 bg-accent/10 hover:bg-accent/20"
2400
- : "hover:bg-accent/10"
2401
- }`}
2402
- >
2403
- <Wrench className="h-3 w-3" />
2404
- Apply…
2405
- </button>
2406
- )}
2407
- {remediationCommands(r).map((command, c, all) => (
2408
- <CopyButton
2409
- key={c}
2410
- text={command}
2411
- label={
2412
- all.length > 1
2413
- ? `Copy command ${c + 1} of step ${i + 1}`
2414
- : `Copy command from step ${i + 1}`
2415
- }
2416
- />
2417
- ))}
2418
- </div>
2419
2975
  </div>
2420
2976
  </div>
2421
2977
  );
2422
2978
  };
2423
2979
  return (
2424
2980
  <div className={`mt-3 space-y-2 ${animate ? "animate-result-in" : ""}`}>
2981
+ {showConclusion && storyShape && (
2982
+ <div
2983
+ className={
2984
+ section === "conclusion"
2985
+ ? ""
2986
+ : "rounded-lg border border-theme-border bg-theme-surface p-3"
2987
+ }
2988
+ >
2989
+ {readOnlyAssessment ? (
2990
+ <div className="mb-1.5 text-[10px] font-semibold uppercase tracking-wide text-theme-text-tertiary">
2991
+ Earlier assessment
2992
+ </div>
2993
+ ) : null}
2994
+ <AssessmentHeadline
2995
+ diagnosis={diagnosis}
2996
+ tone="cause"
2997
+ revisedAfter={revisedAfter}
2998
+ limits={assessmentLimits}
2999
+ copy={assessmentCopy}
3000
+ />
3001
+ </div>
3002
+ )}
2425
3003
  {/* Likely cause — agent-authored, visually prominent without claiming proof. */}
2426
- {showConclusion && rootCause && (
3004
+ {showConclusion && !storyShape && rootCause && (
2427
3005
  <div
2428
3006
  className={
2429
3007
  section === "conclusion"
@@ -2469,7 +3047,7 @@ function DiagnosisResult({
2469
3047
  {section !== "actions" && (
2470
3048
  <div className="mb-2 flex items-center gap-1.5 text-xs font-medium text-theme-text-secondary">
2471
3049
  <Wrench className="h-3.5 w-3.5 text-accent" />
2472
- Remediation
3050
+ {typedSteps ? "Next steps" : "Remediation"}
2473
3051
  </div>
2474
3052
  )}
2475
3053
  {actionNotice && (
@@ -2477,20 +3055,47 @@ function DiagnosisResult({
2477
3055
  )}
2478
3056
  <div id={stepsId}>
2479
3057
  <ol>
2480
- {remediationEntries.map((entry) => (
2481
- <li key={entry.index} value={entry.index + 1}>
2482
- <Collapse
2483
- open={
2484
- !compactActions ||
2485
- showAllSteps ||
2486
- entry.index === primaryActionIndex
2487
- }
2488
- mountLazily
2489
- >
2490
- <div className="pt-2">{renderRemediationStep(entry)}</div>
2491
- </Collapse>
2492
- </li>
2493
- ))}
3058
+ {remediationEntries.map((entry) => {
3059
+ const expanded =
3060
+ !compactActions ||
3061
+ showAllSteps ||
3062
+ entry.index === primaryActionIndex;
3063
+ const step = typedSteps ? steps[entry.index] : undefined;
3064
+ return (
3065
+ <li key={entry.index} value={entry.index + 1}>
3066
+ <Collapse open={expanded} mountLazily>
3067
+ <div className="pt-2">{renderRemediationStep(entry)}</div>
3068
+ </Collapse>
3069
+ {/* A folded step still shows what it is: its kind and
3070
+ first line, one row each, so the alternatives are
3071
+ readable before anyone opens them. */}
3072
+ {!expanded ? (
3073
+ <button
3074
+ type="button"
3075
+ data-step-folded={step?.kind ?? "step"}
3076
+ onClick={() => {
3077
+ setShowAllSteps(true);
3078
+ stepsReveal.revealAfterToggle(true);
3079
+ }}
3080
+ className="mt-1.5 flex w-full min-w-0 items-baseline gap-2 rounded-md px-2 py-1 text-left text-xs text-theme-text-secondary hover:bg-theme-hover"
3081
+ >
3082
+ {step ? (
3083
+ <span className="shrink-0 text-[10px] font-semibold uppercase tracking-wide text-theme-text-tertiary">
3084
+ {STEP_KIND_LABEL[step.kind]}
3085
+ </span>
3086
+ ) : (
3087
+ <span className="shrink-0 text-[10px] font-semibold text-theme-text-tertiary">
3088
+ {entry.index + 1}
3089
+ </span>
3090
+ )}
3091
+ <span className="min-w-0 flex-1 truncate">
3092
+ {remediationHeadline(entry.text)}
3093
+ </span>
3094
+ </button>
3095
+ ) : null}
3096
+ </li>
3097
+ );
3098
+ })}
2494
3099
  </ol>
2495
3100
  </div>
2496
3101
  {compactActions && remediation.length > 1 ? (
@@ -2509,7 +3114,9 @@ function DiagnosisResult({
2509
3114
  ? recValid
2510
3115
  ? "Show only recommended step"
2511
3116
  : "Show only first step"
2512
- : `Show ${hiddenStepCount} more ${hiddenStepCount === 1 ? "step" : "steps"}`}
3117
+ : hiddenStepCount === 1
3118
+ ? "Expand the other step"
3119
+ : "Expand all steps"}
2513
3120
  </button>
2514
3121
  ) : null}
2515
3122
  {!actionNotice && !recValid && (
@@ -2522,10 +3129,12 @@ function DiagnosisResult({
2522
3129
  </div>
2523
3130
  )}
2524
3131
 
2525
- {/* Full analysis — the agent's detailed evidence, on demand. */}
3132
+ {/* Full analysis — the agent's detailed evidence, on demand. Under the
3133
+ story contract Findings renders the story itself, so only the
3134
+ explanation and sources remain here. */}
2526
3135
  {showConclusion &&
2527
- (diagnosis.report ||
2528
- diagnosis.confidence != null ||
3136
+ ((showAnalysisDisclosure && diagnosis.report) ||
3137
+ (showAnalysisDisclosure && diagnosis.confidence != null) ||
2529
3138
  explanation ||
2530
3139
  assessmentAction ||
2531
3140
  assessmentSources) && (
@@ -2534,8 +3143,8 @@ function DiagnosisResult({
2534
3143
  className="flex flex-wrap items-center gap-x-3 gap-y-1 pt-2"
2535
3144
  data-assessment-actions
2536
3145
  >
2537
- {(diagnosis.report ||
2538
- diagnosis.confidence != null ||
3146
+ {((showAnalysisDisclosure && diagnosis.report) ||
3147
+ (showAnalysisDisclosure && diagnosis.confidence != null) ||
2539
3148
  assessmentSources) && (
2540
3149
  <button
2541
3150
  type="button"
@@ -2551,7 +3160,9 @@ function DiagnosisResult({
2551
3160
  open={showAnalysis}
2552
3161
  className="h-3.5 w-3.5"
2553
3162
  />
2554
- {diagnosis.report ? "Full analysis" : "Assessment details"}
3163
+ {showAnalysisDisclosure && diagnosis.report
3164
+ ? "Full analysis"
3165
+ : "Assessment details"}
2555
3166
  </button>
2556
3167
  )}
2557
3168
  {explanation && (
@@ -2597,16 +3208,22 @@ function DiagnosisResult({
2597
3208
  <div id={`${analysisId}-analysis`}>
2598
3209
  <Collapse open={detail === "analysis"} mountLazily>
2599
3210
  <div className="border-t border-theme-border/60 px-3 py-2">
2600
- <p className="mb-2 text-xs text-theme-text-tertiary">
2601
- Agent confidence:{" "}
2602
- {diagnosis.confidence != null
2603
- ? confidenceLabel(diagnosis.confidence)
2604
- : "not stated"}
2605
- {diagnosis.confidence != null ? " · self-reported" : ""}
2606
- </p>
2607
- <AIMarkdown className="text-sm [overflow-wrap:anywhere] [&_h2:first-child]:mt-0 [&_h2]:mb-1.5 [&_h2]:mt-3 [&_h2]:text-xs [&_h2]:font-semibold [&_h2]:uppercase [&_h2]:tracking-wide [&_h2]:text-theme-text-tertiary [&_h3]:text-sm [&_li]:text-theme-text-secondary [&_p]:my-1.5 [&_p]:text-theme-text-secondary">
2608
- {diagnosis.report}
2609
- </AIMarkdown>
3211
+ {showAnalysisDisclosure ? (
3212
+ <>
3213
+ <p className="mb-2 text-xs text-theme-text-tertiary">
3214
+ Agent confidence:{" "}
3215
+ {diagnosis.confidence != null
3216
+ ? confidenceLabel(diagnosis.confidence)
3217
+ : "not stated"}
3218
+ {diagnosis.confidence != null
3219
+ ? " · self-reported"
3220
+ : ""}
3221
+ </p>
3222
+ <AIMarkdown className="text-sm [overflow-wrap:anywhere] [&_h2:first-child]:mt-0 [&_h2]:mb-1.5 [&_h2]:mt-3 [&_h2]:text-xs [&_h2]:font-semibold [&_h2]:uppercase [&_h2]:tracking-wide [&_h2]:text-theme-text-tertiary [&_h3]:text-sm [&_li]:text-theme-text-secondary [&_p]:my-1.5 [&_p]:text-theme-text-secondary">
3223
+ {analysisText}
3224
+ </AIMarkdown>
3225
+ </>
3226
+ ) : null}
2610
3227
  {assessmentSources}
2611
3228
  </div>
2612
3229
  </Collapse>
@@ -2669,6 +3286,17 @@ function DiagnosisResult({
2669
3286
  );
2670
3287
  }
2671
3288
 
3289
+ /** The banner line and the clipboard line for one adverse Radar card must be the same words. */
3290
+ function healthFlagSentence(flag: {
3291
+ status: string;
3292
+ title: string;
3293
+ role?: string;
3294
+ }): string {
3295
+ return flag.status === "contradiction"
3296
+ ? `The agent calls ${flag.title} a ${flag.role} and still reports healthy`
3297
+ : `Radar flagged ${flag.title} · no explanation is linked to it`;
3298
+ }
3299
+
2672
3300
  function AllClearCard({
2673
3301
  diagnosis,
2674
3302
  animate,
@@ -2678,6 +3306,12 @@ function AllClearCard({
2678
3306
  evidenceConflictExplainedBy,
2679
3307
  assessmentAction,
2680
3308
  assessmentSources,
3309
+ storyInline = false,
3310
+ revisedAfter,
3311
+ assessmentLimits,
3312
+ assessmentCopy,
3313
+ healthSignals,
3314
+ onRevealSource,
2681
3315
  }: {
2682
3316
  diagnosis: Diagnosis;
2683
3317
  animate: boolean;
@@ -2687,22 +3321,153 @@ function AllClearCard({
2687
3321
  evidenceConflictExplainedBy?: string[];
2688
3322
  assessmentAction?: ReactNode;
2689
3323
  assessmentSources?: ReactNode;
3324
+ storyInline?: boolean;
3325
+ revisedAfter?: string;
3326
+ /** Reads Radar could not complete for this assessment; listed under Still open. */
3327
+ assessmentLimits?: string[];
3328
+ assessmentCopy?: Pick<AssessmentCopyRadar, "context" | "receipts">;
3329
+ /** Adverse Radar cards with the agent's position on each (story shape). */
3330
+ healthSignals?: InvestigationHealthSignal[];
3331
+ onRevealSource?: (sourceId: string) => void;
2690
3332
  }) {
3333
+ const storyShape = diagnosisHasStoryShape(diagnosis);
2691
3334
  const [showAnalysis, setShowAnalysis] = useState(false);
2692
3335
  const analysisReveal = useDisclosureReveal<HTMLDivElement>();
2693
3336
  const analysisId = useId();
2694
3337
  const report =
2695
- diagnosis.report ||
2696
- "The agent did not identify a problem in the evidence it checked.";
3338
+ (storyShape && !storyInline ? "" : storyPlainText(diagnosis.report)) ||
3339
+ (storyShape
3340
+ ? ""
3341
+ : "The agent did not identify a problem in the evidence it checked.");
2697
3342
  const detailed = report.length > 320 || report.split("\n").length > 2;
2698
- const summary = detailed
2699
- ? "The agent found no active problem in the evidence it reviewed."
2700
- : report;
3343
+ const summary = storyShape
3344
+ ? ""
3345
+ : detailed
3346
+ ? "The agent found no active problem in the evidence it reviewed."
3347
+ : report;
2701
3348
  const explained =
2702
3349
  evidenceConflict &&
2703
3350
  !!evidenceConflictExplainedBy &&
2704
3351
  evidenceConflictExplainedBy.length > 0;
2705
3352
  const unexplainedConflict = evidenceConflict && !explained;
3353
+ // Story shape: the summary and the certainty word are the verdict; adverse
3354
+ // Radar cards the agent explained become lines in Still open, and only a
3355
+ // card the agent's verdict never addressed (or contradicts) keeps a header
3356
+ // that names it — so the reader knows exactly how it relates to the answer.
3357
+ const signals = healthSignals ?? [];
3358
+ const flagged = signals.filter(
3359
+ (signal) =>
3360
+ signal.status === "unaddressed" || signal.status === "contradiction",
3361
+ );
3362
+ const stillOpenSignals = signals
3363
+ .filter(
3364
+ (signal) => signal.status === "explained" || signal.status === "related",
3365
+ )
3366
+ .map((signal) => ({
3367
+ text:
3368
+ signal.status === "explained"
3369
+ ? `Radar flagged ${signal.title} · the agent looked at it and reads it as not a live problem: ${signal.claim}`
3370
+ : `Radar flagged ${signal.title} · the agent reads it as related, but not what matters here: ${signal.claim}`,
3371
+ onReveal:
3372
+ signal.sourceId && onRevealSource
3373
+ ? () => onRevealSource(signal.sourceId!)
3374
+ : undefined,
3375
+ }));
3376
+ // The analysis disclosure (Activity's read-only copy of an earlier healthy
3377
+ // assessment carries its story inline as plain prose), the action slot and
3378
+ // the disclaimer are the same in both shapes.
3379
+ const trailing = (
3380
+ <>
3381
+ {detailed || assessmentAction || assessmentSources ? (
3382
+ <div>
3383
+ <div
3384
+ className="flex flex-wrap items-center gap-3 pt-2"
3385
+ data-assessment-actions
3386
+ >
3387
+ {(detailed || assessmentSources) && (
3388
+ <button
3389
+ type="button"
3390
+ aria-expanded={showAnalysis}
3391
+ aria-controls={analysisId}
3392
+ onClick={() => {
3393
+ setShowAnalysis(!showAnalysis);
3394
+ analysisReveal.revealAfterToggle(!showAnalysis);
3395
+ }}
3396
+ className="flex items-center gap-1.5 rounded-md py-2 text-xs font-medium text-theme-text-secondary hover:text-theme-text-primary"
3397
+ >
3398
+ <CollapseChevron open={showAnalysis} className="h-3.5 w-3.5" />
3399
+ {detailed ? "Full analysis" : "Assessment details"}
3400
+ </button>
3401
+ )}
3402
+ {assessmentAction && (
3403
+ <div className="ml-auto">{assessmentAction}</div>
3404
+ )}
3405
+ </div>
3406
+ <div id={analysisId} ref={analysisReveal.elementRef}>
3407
+ <Collapse open={showAnalysis}>
3408
+ <div className="border-t border-theme-border/60 px-3 py-2">
3409
+ <AIMarkdown className="text-sm [overflow-wrap:anywhere] [&_p]:my-1.5 [&_p]:text-theme-text-secondary [&_p:first-child]:mt-0 [&_p:last-child]:mb-0">
3410
+ {detailed ? report : ""}
3411
+ </AIMarkdown>
3412
+ {assessmentSources}
3413
+ </div>
3414
+ </Collapse>
3415
+ </div>
3416
+ </div>
3417
+ ) : null}
3418
+ {showDisclaimer ? (
3419
+ <div className="flex items-start gap-1 px-0.5 text-[11px] text-theme-text-tertiary">
3420
+ <ShieldCheck className="mt-0.5 h-3 w-3 shrink-0" />
3421
+ <span>AI-generated — verify if symptoms persist</span>
3422
+ </div>
3423
+ ) : null}
3424
+ </>
3425
+ );
3426
+ if (storyShape) {
3427
+ return (
3428
+ <div className={`mt-3 space-y-2 ${animate ? "animate-result-in" : ""}`}>
3429
+ {flagged.map((flag) => (
3430
+ <div
3431
+ key={`${flag.groupId ?? flag.title}-${flag.status}`}
3432
+ data-health-flag={flag.status}
3433
+ className="rounded-md border border-amber-500/40 bg-amber-500/5 px-2.5 py-2 text-xs text-theme-text-primary"
3434
+ >
3435
+ <div className="flex items-center gap-1.5 font-semibold text-amber-500">
3436
+ <AlertTriangle className="h-3.5 w-3.5" aria-hidden />
3437
+ {healthFlagSentence(flag)}
3438
+ </div>
3439
+ <p className="mt-1 text-theme-text-secondary">
3440
+ {flag.status === "contradiction"
3441
+ ? "Read the card before treating this as an all-clear."
3442
+ : "It may be unrelated to what you asked, or missed. Open it before treating this as an all-clear."}
3443
+ {flag.sourceId && onRevealSource ? (
3444
+ <>
3445
+ {" "}
3446
+ <button
3447
+ type="button"
3448
+ onClick={() => onRevealSource(flag.sourceId!)}
3449
+ className="font-medium text-accent-text hover:underline"
3450
+ >
3451
+ View the card
3452
+ </button>
3453
+ </>
3454
+ ) : null}
3455
+ </p>
3456
+ </div>
3457
+ ))}
3458
+ <AssessmentHeadline
3459
+ diagnosis={diagnosis}
3460
+ tone="healthy"
3461
+ revisedAfter={revisedAfter}
3462
+ flags={flagged.map(healthFlagSentence)}
3463
+ limits={assessmentLimits}
3464
+ copy={assessmentCopy}
3465
+ signals={stillOpenSignals}
3466
+ />
3467
+ {trailing}
3468
+ </div>
3469
+ );
3470
+ }
2706
3471
  return (
2707
3472
  <div className={`mt-3 space-y-2 ${animate ? "animate-result-in" : ""}`}>
2708
3473
  <div
@@ -2762,49 +3527,7 @@ function AllClearCard({
2762
3527
  </p>
2763
3528
  ) : null}
2764
3529
  </div>
2765
- {detailed || assessmentAction || assessmentSources ? (
2766
- <div>
2767
- <div
2768
- className="flex flex-wrap items-center gap-3 pt-2"
2769
- data-assessment-actions
2770
- >
2771
- {(detailed || assessmentSources) && (
2772
- <button
2773
- type="button"
2774
- aria-expanded={showAnalysis}
2775
- aria-controls={analysisId}
2776
- onClick={() => {
2777
- setShowAnalysis(!showAnalysis);
2778
- analysisReveal.revealAfterToggle(!showAnalysis);
2779
- }}
2780
- className="flex items-center gap-1.5 rounded-md py-2 text-xs font-medium text-theme-text-secondary hover:text-theme-text-primary"
2781
- >
2782
- <CollapseChevron open={showAnalysis} className="h-3.5 w-3.5" />
2783
- {detailed ? "Full analysis" : "Assessment details"}
2784
- </button>
2785
- )}
2786
- {assessmentAction && (
2787
- <div className="ml-auto">{assessmentAction}</div>
2788
- )}
2789
- </div>
2790
- <div id={analysisId} ref={analysisReveal.elementRef}>
2791
- <Collapse open={showAnalysis}>
2792
- <div className="border-t border-theme-border/60 px-3 py-2">
2793
- <AIMarkdown className="text-sm [overflow-wrap:anywhere] [&_p]:my-1.5 [&_p]:text-theme-text-secondary [&_p:first-child]:mt-0 [&_p:last-child]:mb-0">
2794
- {detailed ? report : ""}
2795
- </AIMarkdown>
2796
- {assessmentSources}
2797
- </div>
2798
- </Collapse>
2799
- </div>
2800
- </div>
2801
- ) : null}
2802
- {showDisclaimer ? (
2803
- <div className="flex items-start gap-1 px-0.5 text-[11px] text-theme-text-tertiary">
2804
- <ShieldCheck className="mt-0.5 h-3 w-3 shrink-0" />
2805
- <span>AI-generated — verify if symptoms persist</span>
2806
- </div>
2807
- ) : null}
3530
+ {trailing}
2808
3531
  </div>
2809
3532
  );
2810
3533
  }
@@ -2815,13 +3538,25 @@ function AllClearCard({
2815
3538
  function InconclusiveCard({
2816
3539
  diagnosis,
2817
3540
  animate,
3541
+ storyInline = false,
3542
+ revisedAfter,
3543
+ assessmentLimits,
3544
+ assessmentCopy,
2818
3545
  }: {
2819
3546
  diagnosis: Diagnosis;
2820
3547
  animate: boolean;
3548
+ storyInline?: boolean;
3549
+ revisedAfter?: string;
3550
+ /** Reads Radar could not complete for this assessment; listed under Still open. */
3551
+ assessmentLimits?: string[];
3552
+ assessmentCopy?: Pick<AssessmentCopyRadar, "context" | "receipts">;
2821
3553
  }) {
3554
+ const storyShape = diagnosisHasStoryShape(diagnosis);
2822
3555
  const text =
2823
- diagnosis.report ||
2824
- "The investigation couldn't reach a clear conclusion — some information was unavailable or the evidence was ambiguous.";
3556
+ (storyShape && !storyInline ? "" : storyPlainText(diagnosis.report)) ||
3557
+ (storyShape
3558
+ ? ""
3559
+ : "The investigation couldn't reach a clear conclusion — some information was unavailable or the evidence was ambiguous.");
2825
3560
  return (
2826
3561
  <div className={`mt-3 space-y-2 ${animate ? "animate-result-in" : ""}`}>
2827
3562
  <div className="rounded-lg border border-theme-border bg-theme-elevated p-3">
@@ -2830,11 +3565,24 @@ function InconclusiveCard({
2830
3565
  <HelpCircle className="h-3.5 w-3.5" />
2831
3566
  Couldn&apos;t determine
2832
3567
  </div>
2833
- <CopyButton text={text} label="Copy assessment" />
3568
+ {storyShape ? null : (
3569
+ <CopyButton text={text} label="Copy assessment" />
3570
+ )}
2834
3571
  </div>
2835
- <AIMarkdown className="text-sm text-theme-text-primary [overflow-wrap:anywhere] [&_code]:font-normal [&_li]:text-theme-text-primary [&_p]:my-1 [&_p]:text-theme-text-primary [&_p:first-child]:mt-0 [&_p:last-child]:mb-0">
2836
- {text}
2837
- </AIMarkdown>
3572
+ {storyShape ? (
3573
+ <AssessmentHeadline
3574
+ diagnosis={diagnosis}
3575
+ tone="inconclusive"
3576
+ revisedAfter={revisedAfter}
3577
+ limits={assessmentLimits}
3578
+ copy={assessmentCopy}
3579
+ />
3580
+ ) : null}
3581
+ {text ? (
3582
+ <AIMarkdown className="mt-2 text-sm text-theme-text-primary [overflow-wrap:anywhere] [&_code]:font-normal [&_li]:text-theme-text-primary [&_p]:my-1 [&_p]:text-theme-text-primary [&_p:first-child]:mt-0 [&_p:last-child]:mb-0">
3583
+ {text}
3584
+ </AIMarkdown>
3585
+ ) : null}
2838
3586
  </div>
2839
3587
  <div className="flex items-start gap-1 px-0.5 text-[11px] text-theme-text-tertiary">
2840
3588
  <ShieldCheck className="mt-0.5 h-3 w-3 shrink-0" />
@@ -3016,6 +3764,19 @@ const COMMAND_BINARIES = new Set([
3016
3764
  "skyhook",
3017
3765
  ]);
3018
3766
 
3767
+ /** The first sentence of a step, without its markdown, for a one-line row. */
3768
+ export function remediationHeadline(step: string): string {
3769
+ const firstLine =
3770
+ step
3771
+ .split(/\r?\n/)
3772
+ .map((line) => line.trim())
3773
+ .find((line) => line && !line.startsWith("```")) ?? "";
3774
+ const sentence = firstLine.split(/(?<=[.!?:])\s/)[0] ?? firstLine;
3775
+ // A step written as "Confirm recovery:" expects its command to follow; as a
3776
+ // folded title the colon dangles.
3777
+ return sentence.replace(/`/g, "").replace(/\*\*/g, "").replace(/:$/, "");
3778
+ }
3779
+
3019
3780
  /**
3020
3781
  * The commands inside a remediation step, in order: every fenced block and
3021
3782
  * every inline code span that reads as a shell invocation. A step's prose is
@@ -3048,25 +3809,48 @@ function looksLikeCommand(span: string): boolean {
3048
3809
  }
3049
3810
 
3050
3811
  function CopyButton({ text, label }: { text: string; label: string }) {
3051
- const [copied, setCopied] = useState(false);
3812
+ const [state, setState] = useState<"idle" | "copied" | "failed">("idle");
3813
+ const copied = state === "copied";
3052
3814
  return (
3053
3815
  <Tooltip
3054
- content={copied ? "Copied" : label}
3816
+ content={
3817
+ state === "copied"
3818
+ ? "Copied"
3819
+ : state === "failed"
3820
+ ? "Copy failed"
3821
+ : label
3822
+ }
3055
3823
  delay={100}
3056
3824
  wrapperClassName="shrink-0"
3057
3825
  >
3058
3826
  <button
3059
- onClick={() => {
3060
- navigator.clipboard?.writeText(text);
3061
- setCopied(true);
3062
- setTimeout(() => setCopied(false), 1200);
3827
+ onClick={async () => {
3828
+ // Report what happened, not what was attempted: the write can be
3829
+ // denied or unavailable, and "Copied" over an empty clipboard is worse
3830
+ // than no button.
3831
+ try {
3832
+ if (!navigator.clipboard) throw new Error("clipboard unavailable");
3833
+ await navigator.clipboard.writeText(text);
3834
+ setState("copied");
3835
+ } catch {
3836
+ setState("failed");
3837
+ }
3838
+ setTimeout(() => setState("idle"), 1200);
3063
3839
  }}
3064
3840
  className="shrink-0 rounded p-1 text-theme-text-tertiary hover:bg-theme-hover hover:text-theme-text-primary"
3065
- aria-label={copied ? `${label} — copied` : label}
3841
+ aria-label={
3842
+ state === "copied"
3843
+ ? `${label} — copied`
3844
+ : state === "failed"
3845
+ ? `${label} — copy failed`
3846
+ : label
3847
+ }
3066
3848
  aria-live="polite"
3067
3849
  >
3068
3850
  {copied ? (
3069
3851
  <Check className="h-3.5 w-3.5 text-emerald-400" />
3852
+ ) : state === "failed" ? (
3853
+ <AlertTriangle className="h-3.5 w-3.5 text-amber-500" />
3070
3854
  ) : (
3071
3855
  <Copy className="h-3.5 w-3.5" />
3072
3856
  )}