@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.
- package/package.json +2 -2
- package/src/App.tsx +6 -4
- package/src/api/client.ts +3 -0
- package/src/api/diagnose.ts +31 -1
- package/src/components/CloudConnectFlow.tsx +173 -13
- package/src/components/CloudFunnelButton.tsx +4 -2
- package/src/components/ConnectionErrorView.tsx +9 -10
- package/src/components/DebugOverlay.tsx +10 -6
- package/src/components/cloudConnectHandoff.test.ts +109 -4
- package/src/components/cloudConnectHandoff.ts +154 -2
- package/src/components/curl/ServiceCurlButton.tsx +19 -26
- package/src/components/diagnose/AgentCase.tsx +18 -5
- package/src/components/diagnose/AnalysisStory.test.tsx +308 -0
- package/src/components/diagnose/AnalysisStory.tsx +564 -0
- package/src/components/diagnose/DiagnoseContext.test.ts +10 -0
- package/src/components/diagnose/DiagnoseContext.tsx +25 -8
- package/src/components/diagnose/DiagnoseSurface.tsx +20 -14
- package/src/components/diagnose/InvestigationEvidencePane.story.test.tsx +771 -0
- package/src/components/diagnose/InvestigationEvidencePane.test.tsx +8 -33
- package/src/components/diagnose/InvestigationEvidencePane.tsx +809 -168
- package/src/components/diagnose/InvestigationResourceEvidence.test.tsx +30 -0
- package/src/components/diagnose/InvestigationResourceEvidence.tsx +20 -0
- package/src/components/diagnose/InvestigationView.tsx +493 -529
- package/src/components/diagnose/investigationCase.test.tsx +267 -129
- package/src/components/diagnose/investigationCase.ts +122 -77
- package/src/components/diagnose/investigationEvidence/adapters/resource.test.ts +27 -0
- package/src/components/diagnose/investigationEvidence/adapters/resource.ts +28 -0
- package/src/components/diagnose/investigationEvidence/builder.ts +11 -2
- package/src/components/diagnose/investigationEvidence/identity.test.ts +25 -7
- package/src/components/diagnose/investigationEvidence/identity.ts +4 -4
- package/src/components/diagnose/investigationEvidence/observations.ts +24 -0
- package/src/components/diagnose/investigationEvidence/projection.test.ts +36 -3
- package/src/components/diagnose/investigationEvidence/types.ts +2 -0
- package/src/components/diagnose/investigationEvidencePresentation.test.ts +2 -0
- package/src/components/diagnose/investigationEvidencePresentation.ts +3 -0
- package/src/components/diagnose/investigationState.test.ts +316 -58
- package/src/components/diagnose/investigationState.ts +257 -44
- package/src/components/diagnose/investigationStory.test.ts +161 -0
- package/src/components/diagnose/investigationStory.ts +207 -0
- package/src/components/diagnose/parts.test.tsx +9 -6
- package/src/components/diagnose/parts.tsx +977 -193
- package/src/components/diagnose/storyParts.test.tsx +426 -0
- package/src/components/diagnose/toolCallLabel.test.ts +51 -0
- package/src/components/diagnose/toolCallLabel.ts +135 -0
- package/src/components/diagnose/useDisclosureReveal.ts +10 -11
- package/src/components/helm/HelmCompareRoute.tsx +18 -4
- package/src/components/helm/HelmReleaseDrawer.tsx +11 -26
- package/src/components/helm/InstallWizard.tsx +8 -3
- package/src/components/home/MCPSetupDialog.tsx +15 -9
- package/src/components/portforward/PortForwardManager.tsx +6 -13
- package/src/components/resource/PrometheusChartsGrid.tsx +3 -3
- package/src/components/resource/WorkloadMetricsHelpDialog.tsx +3 -3
- package/src/components/resource/WorkloadMetricsSection.tsx +15 -21
- package/src/components/resources/PodFilePreview.tsx +3 -3
- package/src/components/resources/renderers/ServiceRenderer.tsx +2 -1
- package/src/components/settings/SettingsDialog.tsx +4 -2
- package/src/components/ui/CommandPalette.tsx +10 -6
- package/src/components/ui/DiagnosticsOverlay.tsx +10 -6
- package/src/components/ui/Disclosure.tsx +47 -0
- package/src/components/ui/Markdown.tsx +23 -11
- package/src/components/ui/ShortcutHelpOverlay.tsx +3 -1
- package/src/components/ui/UpdateNotification.tsx +18 -2
- 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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
//
|
|
634
|
-
//
|
|
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 =
|
|
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 ?
|
|
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
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
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
|
-
|
|
1998
|
-
|
|
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
|
|
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: “
|
|
2453
|
+
{revisedAfter.length > 110
|
|
2454
|
+
? `${revisedAfter.slice(0, 110).trimEnd()}…`
|
|
2455
|
+
: revisedAfter}
|
|
2456
|
+
”
|
|
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)}
|
|
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
|
|
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 &&
|
|
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
|
-
{
|
|
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
|
-
{
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
|
|
2381
|
-
|
|
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
|
-
|
|
2482
|
-
|
|
2483
|
-
|
|
2484
|
-
|
|
2485
|
-
|
|
2486
|
-
|
|
2487
|
-
|
|
2488
|
-
mountLazily
|
|
2489
|
-
|
|
2490
|
-
|
|
2491
|
-
|
|
2492
|
-
|
|
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
|
-
:
|
|
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
|
|
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
|
-
|
|
2601
|
-
|
|
2602
|
-
|
|
2603
|
-
|
|
2604
|
-
|
|
2605
|
-
|
|
2606
|
-
|
|
2607
|
-
|
|
2608
|
-
|
|
2609
|
-
|
|
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
|
-
|
|
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 =
|
|
2699
|
-
? "
|
|
2700
|
-
:
|
|
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
|
-
{
|
|
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
|
-
|
|
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't determine
|
|
2832
3567
|
</div>
|
|
2833
|
-
|
|
3568
|
+
{storyShape ? null : (
|
|
3569
|
+
<CopyButton text={text} label="Copy assessment" />
|
|
3570
|
+
)}
|
|
2834
3571
|
</div>
|
|
2835
|
-
|
|
2836
|
-
|
|
2837
|
-
|
|
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 [
|
|
3812
|
+
const [state, setState] = useState<"idle" | "copied" | "failed">("idle");
|
|
3813
|
+
const copied = state === "copied";
|
|
3052
3814
|
return (
|
|
3053
3815
|
<Tooltip
|
|
3054
|
-
content={
|
|
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
|
-
|
|
3061
|
-
|
|
3062
|
-
|
|
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={
|
|
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
|
)}
|