@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
@@ -1,4 +1,3 @@
1
- import type { ReactNode } from "react";
2
1
  // A view over one durable, server-side investigation run. It SUBSCRIBES to the
3
2
  // run's event stream (replay + live) and reconstructs the transcript; it does not
4
3
  // own the run's lifetime — the server does. So closing the panel or navigating
@@ -6,7 +5,6 @@ import type { ReactNode } from "react";
6
5
  import {
7
6
  investigationDisclosureSettleDelay,
8
7
  prefersReducedMotion,
9
- useDisclosureReveal,
10
8
  } from "./useDisclosureReveal";
11
9
  import {
12
10
  investigationExplanation,
@@ -31,7 +29,11 @@ import {
31
29
  investigationEvidenceCoverageLimited,
32
30
  investigationEvidenceConflictsWithHealthy,
33
31
  investigationHealthConflictExplainedBy,
34
- investigationLiveCaseTurnIndex,
32
+ investigationAssessmentTurnIndexes,
33
+ investigationEvidenceCoverageGaps,
34
+ investigationHealthSignals,
35
+ investigationIsAssessmentTurn,
36
+ investigationSettledAnswerTurnIndexes,
35
37
  investigationEndedBeforeConclusion,
36
38
  type InvestigationHistoryUnavailableState,
37
39
  investigationHistoryUnavailablePresentation,
@@ -41,6 +43,8 @@ import {
41
43
  canInvestigateFurther,
42
44
  } from "./investigationState";
43
45
  import type { AssessmentExplanation } from "./parts";
46
+ import { describeToolCall } from "./toolCallLabel";
47
+ import { AGENT_ROLE_LABELS } from "./AgentCase";
44
48
  import {
45
49
  Fragment,
46
50
  useCallback,
@@ -53,7 +57,7 @@ import {
53
57
  type KeyboardEvent,
54
58
  } from "react";
55
59
  import { useQueryClient } from "@tanstack/react-query";
56
- import { Badge, Collapse, CollapseChevron } from "@skyhook-io/k8s-ui";
60
+ import { Badge } from "@skyhook-io/k8s-ui";
57
61
  import {
58
62
  Send,
59
63
  AlertTriangle,
@@ -61,6 +65,7 @@ import {
61
65
  ArrowRight,
62
66
  Activity,
63
67
  CheckCircle2,
68
+ FileClock,
64
69
  Files,
65
70
  Loader2,
66
71
  } from "lucide-react";
@@ -69,7 +74,6 @@ import {
69
74
  addTurn,
70
75
  stopRun,
71
76
  DiagnoseError,
72
- type Diagnosis,
73
77
  type DiagnoseStreamEvent,
74
78
  type RunSummary,
75
79
  } from "../../api/diagnose";
@@ -83,6 +87,7 @@ import {
83
87
  mergeStartupSignal,
84
88
  upsertTool,
85
89
  type Turn,
90
+ remediationHeadline,
86
91
  } from "./parts";
87
92
  import {
88
93
  investigationActivitySourceDomId,
@@ -91,12 +96,7 @@ import {
91
96
  projectInvestigationEvidence,
92
97
  resolveInvestigationRootCauseEvidence,
93
98
  } from "./investigationEvidence";
94
- import {
95
- resolveInvestigationCase,
96
- investigationCaseItemsStillRendered,
97
- mergeInvestigationCases,
98
- type InvestigationCaseResolution,
99
- } from "./investigationCase";
99
+ import { resolveInvestigationCase } from "./investigationCase";
100
100
  import {
101
101
  InvestigationEvidencePane,
102
102
  partitionInvestigationEvidence,
@@ -106,6 +106,8 @@ import type { DiagnosisResourceRef } from "./diagnoseEvidenceTypes";
106
106
  import { formatInvestigationTarget } from "./target";
107
107
  import { parseContextName } from "../../utils/context-name";
108
108
  import type { InvestigationSourceExcerpt } from "./investigationSourceFocus";
109
+ import { diagnosisHasStoryShape } from "./investigationStory";
110
+ import { groupEvidenceCoverage } from "./investigationEvidencePresentation";
109
111
 
110
112
  const RECHECK_QUESTION =
111
113
  "Did the fix resolve the issue? Re-check the resource's current status and health now, and say whether it's healthy.";
@@ -887,11 +889,10 @@ export function InvestigationView({
887
889
  let lastApplyAttemptIdx = -1;
888
890
  let lastApplyOutcome: Turn["applyOutcome"];
889
891
  turns.forEach((t, i) => {
892
+ // A revising follow-up is an assessment turn too; its steps are the ones
893
+ // Findings offers.
890
894
  if (
891
- t.status === "done" &&
892
- !t.apply &&
893
- !t.explainAssessment &&
894
- (!t.question || t.verify) &&
895
+ investigationIsAssessmentTurn(t) &&
895
896
  (t.diagnosis?.remediation?.length ?? 0) > 0
896
897
  )
897
898
  lastRemediationIdx = i;
@@ -901,26 +902,10 @@ export function InvestigationView({
901
902
  }
902
903
  });
903
904
 
904
- // Initial and explicit verification turns update the Evidence-pane assessment.
905
- // Ordinary questions remain conversational answers in Activity.
906
- const assessmentIndexes: number[] = [];
907
- turns.forEach((t, i) => {
908
- const dx = t.diagnosis;
909
- const structured =
910
- !!dx &&
911
- (!!dx.rootCause ||
912
- (dx.remediation?.length ?? 0) > 0 ||
913
- dx.healthy ||
914
- dx.inconclusive);
915
- if (
916
- t.status === "done" &&
917
- !t.apply &&
918
- !t.explainAssessment &&
919
- (!t.question || t.verify) &&
920
- structured
921
- )
922
- assessmentIndexes.push(i);
923
- });
905
+ // Initial and explicit verification turns update the Findings assessment,
906
+ // and so does a question whose verdict revises it and is complete. Other
907
+ // questions remain conversational answers in Activity.
908
+ const assessmentIndexes = investigationAssessmentTurnIndexes(turns);
924
909
  const currentAssessmentIdx = assessmentIndexes.at(-1) ?? -1;
925
910
  const initialAssessmentIdx = assessmentIndexes[0] ?? -1;
926
911
  const hasMultipleAssessments = assessmentIndexes.length > 1;
@@ -997,101 +982,22 @@ export function InvestigationView({
997
982
  : undefined,
998
983
  [currentAssessment, currentAssessmentIdx, projection],
999
984
  );
1000
- // A follow-up answer that cites evidence takes over the pane's case; the
1001
- // assessment's own items are then listed read-only under it.
1002
- const liveCaseTurnIdx = investigationLiveCaseTurnIndex(
1003
- turns,
1004
- currentAssessmentIdx,
1005
- );
1006
- const liveCaseIsCurrentAssessment = liveCaseTurnIdx === currentAssessmentIdx;
1007
- // The qualification banner and the "Used for assessment" markers describe the
1008
- // assessment on screen, so they always read its own resolution. A later turn's
1009
- // citations only widen the selection (below): replacing the resolution would
1010
- // drop the assessment's qualification and push its cited evidence back into
1011
- // the withheld set.
985
+ // Findings is the newest assessment and nothing else: its own citations,
986
+ // its own case, its own story. Earlier assessments stay complete in
987
+ // Activity; answers that did not revise the assessment stay there too.
1012
988
  const paneResolution = rootCauseEvidenceResolution;
1013
- // The live turn's own resolution stays available for the live turn's own
1014
- // receipt: its sources are its, not the assessment's.
1015
- const liveTurnResolution = useMemo(() => {
1016
- if (liveCaseIsCurrentAssessment) return rootCauseEvidenceResolution;
1017
- const diagnosis = turns[liveCaseTurnIdx]?.diagnosis;
1018
- return diagnosis?.rootCause
1019
- ? resolveInvestigationRootCauseEvidence(
1020
- projection,
1021
- diagnosis.rootCauseEvidence,
1022
- liveCaseTurnIdx,
1023
- )
1024
- : undefined;
1025
- }, [
1026
- liveCaseIsCurrentAssessment,
1027
- rootCauseEvidenceResolution,
1028
- turns,
1029
- liveCaseTurnIdx,
1030
- projection,
1031
- ]);
1032
- const followUpSelectedGroupIds = useMemo(() => {
1033
- if (liveCaseIsCurrentAssessment) return undefined;
1034
- if (liveTurnResolution?.status !== "linked") return undefined;
1035
- return liveTurnResolution.links.flatMap((link) =>
1036
- link.originalGroupId
1037
- ? [{ groupId: link.originalGroupId, source: link.source }]
1038
- : [],
1039
- );
1040
- }, [liveCaseIsCurrentAssessment, liveTurnResolution]);
1041
- // The live turn's own case, before anything is carried onto it. Its source
1042
- // receipt must list what IT cited, not what the merge brought along.
1043
- const liveTurnCase = useMemo(
1044
- () =>
1045
- liveCaseIsCurrentAssessment
1046
- ? investigationCase
1047
- : resolveInvestigationCase(
1048
- projection,
1049
- turns[liveCaseTurnIdx]?.diagnosis,
1050
- liveCaseTurnIdx,
1051
- ),
1052
- [
1053
- liveCaseIsCurrentAssessment,
1054
- investigationCase,
1055
- projection,
1056
- turns,
1057
- liveCaseTurnIdx,
1058
- ],
1059
- );
1060
- const paneCase = useMemo(() => {
1061
- const live = liveTurnCase;
1062
- const earlier: InvestigationCaseResolution[] = [];
1063
- for (let i = turns.length - 1; i >= 0; i -= 1) {
1064
- const diagnosis = turns[i]?.diagnosis;
1065
- if (i === liveCaseTurnIdx || !diagnosis) continue;
1066
- // Reuse the assessment's own resolution rather than resolving it a
1067
- // second time: same projection, same result, and it keeps the items the
1068
- // conflict banner reasons about the ones the merge received.
1069
- earlier.push(
1070
- i === currentAssessmentIdx && investigationCase
1071
- ? investigationCase
1072
- : resolveInvestigationCase(projection, diagnosis, i),
1073
- );
1074
- }
1075
- return mergeInvestigationCases(live, earlier);
1076
- }, [
1077
- liveTurnCase,
1078
- investigationCase,
1079
- currentAssessmentIdx,
1080
- projection,
1081
- turns,
1082
- liveCaseTurnIdx,
1083
- ]);
989
+ const storyShape = diagnosisHasStoryShape(currentAssessment?.diagnosis);
1084
990
  const visibleEvidenceGroupIds = useMemo(
1085
991
  () =>
1086
992
  new Set(
1087
993
  partitionInvestigationEvidence(
1088
994
  projection.groups,
1089
995
  paneResolution,
1090
- paneCase,
1091
- followUpSelectedGroupIds,
996
+ investigationCase,
997
+ storyShape,
1092
998
  ).collectionByGroup.keys(),
1093
999
  ),
1094
- [projection.groups, paneResolution, paneCase, followUpSelectedGroupIds],
1000
+ [projection.groups, paneResolution, investigationCase, storyShape],
1095
1001
  );
1096
1002
  const evidenceStepIdsByTurn = useMemo(
1097
1003
  () =>
@@ -1378,47 +1284,93 @@ export function InvestigationView({
1378
1284
  : verificationError;
1379
1285
  const displayedStatusCheckError =
1380
1286
  displayedVerificationError || applyOutcomeUncertain;
1381
- const currentKeyFindingCount = projection.groups.filter(
1382
- (group) => !group.historical && group.latest.tier === "key",
1383
- ).length;
1384
1287
  const findingsTabAccessibleLabel =
1385
1288
  "Findings: current assessment, Radar evidence, and next steps";
1386
1289
  const currentAssessmentCoverageLimited = investigationEvidenceCoverageLimited(
1387
1290
  currentAssessmentProjection,
1291
+ kind,
1388
1292
  );
1293
+ // The agent's notes per source. In the legacy shape this is Assessment
1294
+ // details; in the story shape the notes sit on the cards, so it appears
1295
+ // only inside Captured results and only when something has no card: a
1296
+ // note that pins to no single observation, or a citation Radar rejected.
1297
+ const assessmentSourcesNode =
1298
+ currentAssessment?.diagnosis &&
1299
+ (rootCauseEvidenceResolution?.links.length ||
1300
+ investigationCase?.items.length ||
1301
+ currentAssessment.diagnosis.unlinkedEvidence ||
1302
+ currentAssessment.diagnosis.evidenceMalformed) ? (
1303
+ <AssessmentSources
1304
+ renderedGroupIds={visibleEvidenceGroupIds}
1305
+ resolution={rootCauseEvidenceResolution}
1306
+ investigationCase={investigationCase}
1307
+ unlinkedEvidence={currentAssessment.diagnosis.unlinkedEvidence}
1308
+ evidenceMalformed={currentAssessment.diagnosis.evidenceMalformed}
1309
+ onViewSource={viewActivitySource}
1310
+ />
1311
+ ) : undefined;
1312
+ const recordNotes =
1313
+ storyShape &&
1314
+ (investigationCase?.items.some((item) => item.placement === "source") ||
1315
+ currentAssessment?.diagnosis?.unlinkedEvidence ||
1316
+ currentAssessment?.diagnosis?.evidenceMalformed)
1317
+ ? assessmentSourcesNode
1318
+ : undefined;
1319
+ const assessmentLimits = useMemo(() => {
1320
+ const lines = groupEvidenceCoverage(currentAssessmentProjection.limitations)
1321
+ // A capped preview or a history bound is bookkeeping for the record; Still
1322
+ // open keeps the reads Radar could not complete.
1323
+ .filter((group) => !group.historyOnly && !group.bookkeepingOnly)
1324
+ .map((group) => `${group.label}: ${group.summary}`);
1325
+ // Two coverage gaps carry no limitation of their own; name the one that
1326
+ // applies.
1327
+ const gaps = investigationEvidenceCoverageGaps(
1328
+ currentAssessmentProjection,
1329
+ kind,
1330
+ );
1331
+ if (gaps.noEvidence)
1332
+ lines.push("No evidence was recorded for this assessment");
1333
+ else if (gaps.noTargetDiagnosis)
1334
+ lines.push(
1335
+ "Radar's full diagnose of this workload was not collected, so this check is partial",
1336
+ );
1337
+ return lines;
1338
+ }, [currentAssessmentProjection, kind]);
1339
+ const healthSignals = useMemo(
1340
+ () =>
1341
+ currentAssessment?.diagnosis?.healthy
1342
+ ? investigationHealthSignals(projection, investigationCase?.items)
1343
+ : [],
1344
+ [currentAssessment, projection, investigationCase],
1345
+ );
1346
+ // While the first assessment is still running the pane keeps the story
1347
+ // shape, so the page fills in rather than rearranging when the verdict lands.
1348
+ const storyShell =
1349
+ !hosted && !currentAssessment && lastTurn?.status === "running";
1389
1350
  const currentAssessmentEvidenceConflict =
1390
1351
  currentAssessment?.diagnosis?.healthy === true &&
1391
1352
  investigationEvidenceConflictsWithHealthy(projection);
1392
1353
  // The banner qualifies THIS assessment, so only this assessment's own case
1393
- // may reframe it. `paneCase` also carries a later answer's items and notes
1394
- // inherited from superseded assessments; neither of those spoke about this
1395
- // verdict, and letting them soften it would let an unrelated follow-up
1396
- // retire a warning the reader still needs.
1354
+ // may reframe it — and that is the only case the pane renders.
1397
1355
  const currentAssessmentEvidenceConflictExplainedBy = useMemo(() => {
1398
1356
  if (!currentAssessmentEvidenceConflict) return undefined;
1399
- // Two conditions, and both are required. The note must belong to THIS
1400
- // assessment — a later answer or a superseded assessment did not speak
1401
- // about this verdict. And it must survive into the case the pane actually
1402
- // renders: the merge lets a later turn take over a group, and a banner
1403
- // that points at a note the reader cannot find is worse than no banner.
1404
- const qualifying = investigationCaseItemsStillRendered(
1405
- investigationCase?.items,
1406
- paneCase?.items,
1407
- );
1408
1357
  return (
1409
- investigationHealthConflictExplainedBy(projection, qualifying) ??
1410
- undefined
1358
+ investigationHealthConflictExplainedBy(
1359
+ projection,
1360
+ investigationCase?.items,
1361
+ ) ?? undefined
1411
1362
  );
1412
- }, [
1413
- currentAssessmentEvidenceConflict,
1414
- projection,
1415
- investigationCase,
1416
- paneCase,
1417
- ]);
1363
+ }, [currentAssessmentEvidenceConflict, projection, investigationCase]);
1364
+ const settledAnswerTurns = useMemo(
1365
+ () => investigationSettledAnswerTurnIndexes(turns, currentAssessmentIdx),
1366
+ [turns, currentAssessmentIdx],
1367
+ );
1418
1368
  const hasEvidenceCollectedAfterAssessment =
1419
1369
  currentAssessmentIdx >= 0 &&
1420
1370
  projection.sources.some(
1421
- (source) => source.turnIndex > currentAssessmentIdx,
1371
+ (source) =>
1372
+ source.turnIndex > currentAssessmentIdx &&
1373
+ !settledAnswerTurns.has(source.turnIndex),
1422
1374
  );
1423
1375
  const assessmentNeedsCurrentStateVerification =
1424
1376
  investigationAssessmentNeedsCurrentStateVerification({
@@ -1434,9 +1386,112 @@ export function InvestigationView({
1434
1386
  const earlierPlan =
1435
1387
  assessmentNeedsCurrentStateVerification ||
1436
1388
  hasEvidenceCollectedAfterAssessment;
1389
+ // The jump link names the decision it leads to, not the section.
1390
+ const nextStepLabel = (() => {
1391
+ const dx = currentAssessment?.diagnosis;
1392
+ if (!dx) return "Next steps ↓";
1393
+ const index = dx.recommendedIndex ? dx.recommendedIndex - 1 : 0;
1394
+ const text =
1395
+ dx.steps?.[index]?.text ?? dx.remediation?.[index] ?? dx.remediation?.[0];
1396
+ if (!text) return "Next steps ↓";
1397
+ const headline = remediationHeadline(text);
1398
+ return `Next: ${headline.length > 56 ? `${headline.slice(0, 56).trimEnd()}…` : headline} ↓`;
1399
+ })();
1400
+ // What a reader in a channel needs with the verdict: where, when, and the
1401
+ // receipts behind it, cause first, at most three.
1402
+ const assessmentCopy = useMemo(() => {
1403
+ const items = [...(investigationCase?.items ?? [])]
1404
+ .filter((item) => item.placement === "card" && item.observation)
1405
+ .sort((a, b) => Number(b.role === "cause") - Number(a.role === "cause"))
1406
+ .slice(0, 3);
1407
+ return {
1408
+ context: {
1409
+ target: formatInvestigationTarget(run),
1410
+ cluster: run.context,
1411
+ startedAt: run.createdAt,
1412
+ agent: agentLabel,
1413
+ },
1414
+ receipts: items.map((item) => {
1415
+ const observation = item.observation!;
1416
+ const lines =
1417
+ observation.data.type === "logs"
1418
+ ? (observation.data.logs?.lines ?? []).slice(-2)
1419
+ : observation.summary
1420
+ ? [observation.summary]
1421
+ : [];
1422
+ return {
1423
+ title: observation.title,
1424
+ role: item.role ? AGENT_ROLE_LABELS[item.role] : undefined,
1425
+ lines,
1426
+ gap: item.gap || undefined,
1427
+ };
1428
+ }),
1429
+ };
1430
+ }, [investigationCase, run, agentLabel]);
1431
+ // While the agent works, name the read in flight instead of describing the
1432
+ // pane's mechanics, with how many reads so far and how long it has been.
1433
+ // The clock starts when this client sees the turn running; a replay of a
1434
+ // finished turn never shows it.
1435
+ const turnRunning = lastTurn?.status === "running";
1436
+ const runningSinceRef = useRef<number | undefined>(undefined);
1437
+ const [, setRunningTick] = useState(0);
1438
+ useEffect(() => {
1439
+ if (!turnRunning) {
1440
+ runningSinceRef.current = undefined;
1441
+ return;
1442
+ }
1443
+ runningSinceRef.current ??= Date.now();
1444
+ const id = window.setInterval(() => setRunningTick((t) => t + 1), 1000);
1445
+ return () => window.clearInterval(id);
1446
+ }, [turnRunning]);
1447
+ // A finished read is not what the agent is doing eight seconds later; after
1448
+ // a quiet spell the label says it is thinking rather than naming a stale read.
1449
+ const lastTool = [...(lastTurn?.timeline ?? [])]
1450
+ .reverse()
1451
+ .find((item) => item.kind === "tool");
1452
+ const toolSignature = lastTool
1453
+ ? `${lastTool.id}:${lastTool.status}`
1454
+ : undefined;
1455
+ const toolChangedAtRef = useRef<number>(Date.now());
1456
+ const toolSeenRef = useRef(toolSignature);
1457
+ useEffect(() => {
1458
+ toolSeenRef.current = toolSignature;
1459
+ toolChangedAtRef.current = Date.now();
1460
+ }, [toolSignature]);
1461
+ const investigatingLabel = (() => {
1462
+ const items = lastTurn?.timeline ?? [];
1463
+ const reads = items.filter((item) => item.kind === "tool").length;
1464
+ // The render that first shows a changed tool is not quiet yet; the effect
1465
+ // that stamps the change runs after it.
1466
+ const quietFor =
1467
+ toolSeenRef.current === toolSignature
1468
+ ? Date.now() - toolChangedAtRef.current
1469
+ : 0;
1470
+ let current = "Investigating";
1471
+ if (lastTool && lastTool.kind === "tool" && lastTool.tool) {
1472
+ current =
1473
+ lastTool.status === "running" || quietFor < 8000
1474
+ ? describeToolCall(lastTool.tool, lastTool.summary)
1475
+ : "Thinking";
1476
+ }
1477
+ const elapsed = runningSinceRef.current
1478
+ ? Math.round((Date.now() - runningSinceRef.current) / 1000)
1479
+ : 0;
1480
+ // The tool text changes width as it changes; the counters sit apart on
1481
+ // the right so they never move with it.
1482
+ return {
1483
+ current: `${current}…`,
1484
+ meta: [
1485
+ reads > 0 ? `${reads} ${reads === 1 ? "read" : "reads"}` : undefined,
1486
+ elapsed >= 2 ? `${elapsed} s` : undefined,
1487
+ ]
1488
+ .filter(Boolean)
1489
+ .join(" · "),
1490
+ };
1491
+ })();
1437
1492
  const showSplitWorkspace = maximized;
1438
1493
  const splitGridClass = showSplitWorkspace
1439
- ? "@min-[1000px]/investigation:grid-cols-[minmax(360px,520px)_minmax(0,1fr)]"
1494
+ ? "@min-[1000px]/investigation:grid-cols-[minmax(320px,min(30%,520px))_minmax(0,1fr)]"
1440
1495
  : "";
1441
1496
  const splitTabClass = showSplitWorkspace
1442
1497
  ? "@min-[1000px]/investigation:hidden"
@@ -1590,6 +1645,50 @@ export function InvestigationView({
1590
1645
  </div>
1591
1646
  ) : null;
1592
1647
 
1648
+ const nextStepsSection =
1649
+ hasNextSteps && currentAssessment?.diagnosis ? (
1650
+ <section
1651
+ ref={nextStepsRef}
1652
+ tabIndex={-1}
1653
+ aria-labelledby={`${workspaceId}-next-steps`}
1654
+ className="investigation-next-steps rounded-xl border p-4 outline-none focus-visible:ring-2 focus-visible:ring-accent/50"
1655
+ >
1656
+ <h2
1657
+ id={`${workspaceId}-next-steps`}
1658
+ className="text-lg font-semibold text-theme-text-primary"
1659
+ >
1660
+ {earlierPlan ? "Earlier proposed steps" : "Next steps"}
1661
+ </h2>
1662
+ <ResultCard
1663
+ diagnosis={currentAssessment.diagnosis}
1664
+ section="actions"
1665
+ compactActions
1666
+ actionNotice={
1667
+ earlierPlan
1668
+ ? assessmentNeedsCurrentStateVerification
1669
+ ? "Proposed before the apply attempt. Current state has not been verified."
1670
+ : "Proposed before the latest evidence. Reassess before applying."
1671
+ : undefined
1672
+ }
1673
+ onApply={
1674
+ canOfferInvestigationApply({
1675
+ currentAssessmentIdx,
1676
+ lastRemediationIdx,
1677
+ lastApplyAttemptIdx,
1678
+ localApplyAttemptAssessmentIdx,
1679
+ interactionsBlocked,
1680
+ hosted,
1681
+ hasNewerEvidence: hasEvidenceCollectedAfterAssessment,
1682
+ })
1683
+ ? requestApply
1684
+ : undefined
1685
+ }
1686
+ animate={currentAssessment.animateResult !== false}
1687
+ showDisclaimer={false}
1688
+ />
1689
+ </section>
1690
+ ) : undefined;
1691
+
1593
1692
  return (
1594
1693
  <div
1595
1694
  data-investigation-workspace
@@ -1846,46 +1945,56 @@ export function InvestigationView({
1846
1945
  ? retryDiagnosis
1847
1946
  : undefined
1848
1947
  }
1849
- hideConclusion={assessmentIndexes.includes(index)}
1948
+ assessment={assessmentIndexes.includes(index)}
1949
+ // The current assessment is Findings; Activity
1950
+ // keeps a pointer to it and the full verdict of
1951
+ // every earlier one, so a revision never erases
1952
+ // what was said before.
1953
+ hideConclusion={index === currentAssessmentIdx}
1954
+ explanation={
1955
+ assessmentIndexes.includes(index) &&
1956
+ index !== currentAssessmentIdx
1957
+ ? explanationFor(turn)
1958
+ : undefined
1959
+ }
1850
1960
  assessmentSources={(() => {
1851
- // Answer turns show what they cited; the live
1852
- // one also drives Findings, earlier ones are
1853
- // read-only.
1961
+ // Every turn with a case shows what it cited;
1962
+ // only the current assessment's notes annotate
1963
+ // Findings, the rest are read-only here.
1854
1964
  if (
1855
- !turn.question ||
1856
- turn.verify ||
1857
1965
  turn.apply ||
1858
1966
  turn.status !== "done" ||
1859
- !turn.diagnosis
1967
+ !turn.diagnosis ||
1968
+ index === currentAssessmentIdx
1860
1969
  )
1861
1970
  return undefined;
1862
- const answerCase =
1863
- index === liveCaseTurnIdx
1864
- ? liveTurnCase
1865
- : resolveInvestigationCase(
1866
- projection,
1867
- turn.diagnosis,
1868
- index,
1869
- );
1870
- const answerResolution =
1871
- index === liveCaseTurnIdx
1872
- ? liveTurnResolution
1873
- : undefined;
1874
- return answerCase?.items.length ||
1875
- answerResolution?.links.length ||
1876
- turn.diagnosis?.unlinkedEvidence ||
1877
- turn.diagnosis?.evidenceMalformed ? (
1971
+ const turnCase = resolveInvestigationCase(
1972
+ projection,
1973
+ turn.diagnosis,
1974
+ index,
1975
+ );
1976
+ const turnResolution = turn.diagnosis.rootCause
1977
+ ? resolveInvestigationRootCauseEvidence(
1978
+ projection,
1979
+ turn.diagnosis.rootCauseEvidence,
1980
+ index,
1981
+ )
1982
+ : undefined;
1983
+ return turnCase.items.length ||
1984
+ turnResolution?.links.length ||
1985
+ turn.diagnosis.unlinkedEvidence ||
1986
+ turn.diagnosis.evidenceMalformed ? (
1878
1987
  <AssessmentSources
1879
1988
  renderedGroupIds={visibleEvidenceGroupIds}
1880
- resolution={answerResolution}
1881
- investigationCase={answerCase}
1989
+ resolution={turnResolution}
1990
+ investigationCase={turnCase}
1882
1991
  unlinkedEvidence={
1883
- turn.diagnosis?.unlinkedEvidence
1992
+ turn.diagnosis.unlinkedEvidence
1884
1993
  }
1885
1994
  evidenceMalformed={
1886
- turn.diagnosis?.evidenceMalformed
1995
+ turn.diagnosis.evidenceMalformed
1887
1996
  }
1888
- readOnly={index !== liveCaseTurnIdx}
1997
+ readOnly
1889
1998
  onViewSource={viewActivitySource}
1890
1999
  />
1891
2000
  ) : undefined;
@@ -1929,9 +2038,7 @@ export function InvestigationView({
1929
2038
  : "Assessment ready"}
1930
2039
  </span>
1931
2040
  <span className="block text-[11px] text-theme-text-tertiary">
1932
- {currentKeyFindingCount > 0
1933
- ? `${currentKeyFindingCount} ${currentKeyFindingCount === 1 ? "key finding" : "key findings"} ready to review`
1934
- : "Findings compiled from Radar results"}
2041
+ Assessment, evidence and next steps
1935
2042
  </span>
1936
2043
  </span>
1937
2044
  <span className="inline-flex shrink-0 items-center gap-1 text-xs font-medium text-accent-text">
@@ -2042,7 +2149,7 @@ export function InvestigationView({
2042
2149
  }}
2043
2150
  className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-3 py-3 [overflow-anchor:none] [scrollbar-gutter:stable]"
2044
2151
  >
2045
- <div ref={evidenceContentRef} className="min-w-0 space-y-6">
2152
+ <div ref={evidenceContentRef} className="min-w-0 space-y-4">
2046
2153
  {rebuildingReplay ? (
2047
2154
  <div className="flex min-h-28 flex-col items-center justify-center rounded-lg border border-dashed border-theme-border px-4 text-center">
2048
2155
  {historyUnavailablePresentation &&
@@ -2067,221 +2174,211 @@ export function InvestigationView({
2067
2174
  </p>
2068
2175
  </div>
2069
2176
  ) : (
2070
- <>
2071
- <section
2072
- aria-labelledby={`${workspaceId}-assessment-heading`}
2073
- className="investigation-assessment rounded-xl border p-4"
2074
- >
2075
- <div className="flex flex-wrap items-center gap-1.5">
2076
- <h2
2077
- id={`${workspaceId}-assessment-heading`}
2078
- className="text-lg font-semibold text-theme-text-primary"
2079
- >
2080
- {assessmentNeedsCurrentStateVerification
2081
- ? "Assessment before apply"
2082
- : hasEvidenceCollectedAfterAssessment
2083
- ? "Earlier assessment"
2084
- : !currentAssessment
2085
- ? "Assessment"
2086
- : currentAssessment.verify
2087
- ? "Verification result"
2088
- : currentAssessmentIdx ===
2089
- initialAssessmentIdx &&
2090
- hasMultipleAssessments
2091
- ? "Initial assessment"
2092
- : "Assessment"}
2093
- </h2>
2177
+ <div className="space-y-4">
2178
+ {/* One column, one reading order: assessment, analysis, next
2179
+ steps. The page already holds four columns; Findings is
2180
+ not a fifth, and it fills the pane it is given. */}
2181
+ <div className="space-y-3">
2182
+ <section
2183
+ aria-labelledby={`${workspaceId}-assessment-heading`}
2184
+ className="investigation-assessment rounded-xl border p-3"
2185
+ >
2186
+ <div className="flex flex-wrap items-center gap-1.5">
2187
+ <h2
2188
+ id={`${workspaceId}-assessment-heading`}
2189
+ className="text-lg font-semibold text-theme-text-primary"
2190
+ >
2191
+ {assessmentNeedsCurrentStateVerification
2192
+ ? "Assessment before apply"
2193
+ : hasEvidenceCollectedAfterAssessment
2194
+ ? "Earlier assessment"
2195
+ : !currentAssessment
2196
+ ? "Assessment"
2197
+ : currentAssessment.verify
2198
+ ? "Verification result"
2199
+ : currentAssessmentIdx ===
2200
+ initialAssessmentIdx &&
2201
+ hasMultipleAssessments
2202
+ ? "Initial assessment"
2203
+ : "Assessment"}
2204
+ </h2>
2205
+ {assessmentNeedsCurrentStateVerification ? (
2206
+ <Badge severity="warning" size="sm">
2207
+ Current state unverified
2208
+ </Badge>
2209
+ ) : null}
2210
+ {hasEvidenceCollectedAfterAssessment &&
2211
+ !assessmentNeedsCurrentStateVerification ? (
2212
+ <Badge severity="info" size="sm">
2213
+ Newer evidence below
2214
+ </Badge>
2215
+ ) : null}
2216
+ {verificationRunning || verificationPending ? (
2217
+ <Badge severity="info" size="sm">
2218
+ Verifying…
2219
+ </Badge>
2220
+ ) : null}
2221
+ </div>
2094
2222
  {assessmentNeedsCurrentStateVerification ? (
2095
- <Badge severity="warning" size="sm">
2096
- Current state unverified
2097
- </Badge>
2098
- ) : null}
2099
- {hasEvidenceCollectedAfterAssessment &&
2100
- !assessmentNeedsCurrentStateVerification ? (
2101
- <Badge severity="info" size="sm">
2102
- Newer evidence below
2103
- </Badge>
2223
+ <p className="mt-0.5 text-xs text-theme-text-tertiary">
2224
+ {verificationRunning || verificationPending
2225
+ ? "This assessment predates the apply attempt. Radar is checking the current state now."
2226
+ : "This assessment predates the apply attempt; cluster state after it has not been verified."}
2227
+ </p>
2228
+ ) : hasEvidenceCollectedAfterAssessment ? (
2229
+ <p className="mt-0.5 text-xs text-theme-text-tertiary">
2230
+ Some evidence below was collected after this
2231
+ assessment. Validate the conclusion against it before
2232
+ acting.
2233
+ </p>
2104
2234
  ) : null}
2105
- {verificationRunning || verificationPending ? (
2106
- <Badge severity="info" size="sm">
2107
- Verifying…
2108
- </Badge>
2109
- ) : null}
2110
- </div>
2111
- {assessmentNeedsCurrentStateVerification ? (
2112
- <p className="mt-0.5 text-xs text-theme-text-tertiary">
2113
- {verificationRunning || verificationPending
2114
- ? "This assessment predates the apply attempt. Radar is checking the current state now."
2115
- : "This assessment predates the apply attempt; cluster state after it has not been verified."}
2116
- </p>
2117
- ) : hasEvidenceCollectedAfterAssessment ? (
2118
- <p className="mt-0.5 text-xs text-theme-text-tertiary">
2119
- Some evidence below was collected after this assessment.
2120
- Validate the conclusion against it before acting.
2121
- </p>
2122
- ) : null}
2123
- {currentAssessment?.diagnosis ? (
2124
- <ResultCard
2125
- key={
2126
- currentAssessment.resultSequence ??
2127
- currentAssessmentIdx
2128
- }
2129
- diagnosis={currentAssessment.diagnosis}
2130
- assessmentSources={
2131
- // An assessment whose every note was rejected has no
2132
- // items and no links, and the loss is the only thing
2133
- // there is to say about it.
2134
- rootCauseEvidenceResolution?.links.length ||
2135
- investigationCase?.items.length ||
2136
- currentAssessment.diagnosis.unlinkedEvidence ||
2137
- currentAssessment.diagnosis.evidenceMalformed ? (
2138
- <AssessmentSources
2139
- renderedGroupIds={visibleEvidenceGroupIds}
2140
- resolution={rootCauseEvidenceResolution}
2141
- investigationCase={investigationCase}
2142
- unlinkedEvidence={
2143
- currentAssessment.diagnosis?.unlinkedEvidence
2144
- }
2145
- evidenceMalformed={
2146
- currentAssessment.diagnosis?.evidenceMalformed
2147
- }
2148
- readOnly={!liveCaseIsCurrentAssessment}
2149
- onViewSource={viewActivitySource}
2235
+ {currentAssessment?.diagnosis ? (
2236
+ <ResultCard
2237
+ key={
2238
+ currentAssessment.resultSequence ??
2239
+ currentAssessmentIdx
2240
+ }
2241
+ diagnosis={currentAssessment.diagnosis}
2242
+ assessmentLimits={
2243
+ storyShape ? assessmentLimits : undefined
2244
+ }
2245
+ assessmentCopy={assessmentCopy}
2246
+ healthSignals={storyShape ? healthSignals : undefined}
2247
+ onRevealSource={viewEvidenceSource}
2248
+ assessmentSources={
2249
+ storyShape ? undefined : assessmentSourcesNode
2250
+ }
2251
+ assessmentAction={
2252
+ hasNextSteps ? (
2253
+ <button
2254
+ type="button"
2255
+ onClick={() => {
2256
+ const section = nextStepsRef.current;
2257
+ const scroller = evidenceScrollRef.current;
2258
+ if (!section || !scroller) return;
2259
+ section.focus({ preventScroll: true });
2260
+ scroller.scrollTo({
2261
+ top:
2262
+ scroller.scrollTop +
2263
+ section.getBoundingClientRect().top -
2264
+ scroller.getBoundingClientRect().top -
2265
+ 12,
2266
+ behavior: prefersReducedMotion()
2267
+ ? "auto"
2268
+ : "smooth",
2269
+ });
2270
+ }}
2271
+ className="ml-auto rounded-md px-2 py-1 text-xs font-medium text-accent-text hover:bg-theme-hover"
2272
+ >
2273
+ {earlierPlan
2274
+ ? "Earlier proposed steps ↓"
2275
+ : nextStepLabel}
2276
+ </button>
2277
+ ) : null
2278
+ }
2279
+ explanation={explanationFor(currentAssessment)}
2280
+ section="conclusion"
2281
+ animate={currentAssessment.animateResult !== false}
2282
+ showDisclaimer={false}
2283
+ revisedAfter={
2284
+ currentAssessment.question &&
2285
+ !currentAssessment.verify &&
2286
+ hasMultipleAssessments
2287
+ ? currentAssessment.question
2288
+ : undefined
2289
+ }
2290
+ coverageLimited={currentAssessmentCoverageLimited}
2291
+ evidenceConflict={currentAssessmentEvidenceConflict}
2292
+ evidenceConflictExplainedBy={
2293
+ currentAssessmentEvidenceConflictExplainedBy
2294
+ }
2295
+ />
2296
+ ) : (
2297
+ <div className="mt-2 flex items-center gap-2 rounded-md bg-theme-surface/60 px-2.5 py-2 text-xs text-theme-text-tertiary">
2298
+ {busy || requestPending ? (
2299
+ <span
2300
+ className="h-1.5 w-1.5 shrink-0 animate-pulse rounded-full bg-accent"
2301
+ aria-hidden
2150
2302
  />
2151
- ) : undefined
2152
- }
2153
- assessmentAction={
2154
- hasNextSteps ? (
2155
- <button
2156
- type="button"
2157
- onClick={() => {
2158
- const section = nextStepsRef.current;
2159
- const scroller = evidenceScrollRef.current;
2160
- if (!section || !scroller) return;
2161
- section.focus({ preventScroll: true });
2162
- scroller.scrollTo({
2163
- top:
2164
- scroller.scrollTop +
2165
- section.getBoundingClientRect().top -
2166
- scroller.getBoundingClientRect().top -
2167
- 12,
2168
- behavior: prefersReducedMotion()
2169
- ? "auto"
2170
- : "smooth",
2171
- });
2172
- }}
2173
- className="ml-auto rounded-md px-2 py-1 text-xs font-medium text-accent-text hover:bg-theme-hover"
2174
- >
2175
- {earlierPlan
2176
- ? "Earlier proposed steps ↓"
2177
- : "Next steps ↓"}
2178
- </button>
2179
- ) : null
2180
- }
2181
- explanation={explanationFor(currentAssessment)}
2182
- section="conclusion"
2183
- animate={currentAssessment.animateResult !== false}
2184
- showDisclaimer={false}
2185
- coverageLimited={currentAssessmentCoverageLimited}
2186
- evidenceConflict={currentAssessmentEvidenceConflict}
2187
- evidenceConflictExplainedBy={
2188
- currentAssessmentEvidenceConflictExplainedBy
2189
- }
2190
- />
2191
- ) : (
2192
- <div className="mt-2 flex items-center gap-2 rounded-md bg-theme-surface/60 px-2.5 py-2 text-xs text-theme-text-tertiary">
2193
- {busy || requestPending ? (
2194
- <span
2195
- className="h-1.5 w-1.5 shrink-0 animate-pulse rounded-full bg-accent"
2196
- aria-hidden
2197
- />
2198
- ) : (
2199
- <Activity
2200
- className="h-3.5 w-3.5 shrink-0"
2201
- aria-hidden
2202
- />
2203
- )}
2204
- <span>
2205
- {busy || requestPending
2206
- ? "Forming an assessment as evidence arrives…"
2207
- : "The agent did not provide a final assessment."}
2208
- </span>
2209
- </div>
2210
- )}
2211
- {displayedStatusCheckError ? (
2212
- <div className="mt-2 flex items-center gap-2 rounded-lg border border-red-500/30 bg-red-500/5 px-3 py-2 text-xs text-theme-text-secondary">
2213
- <AlertTriangle className="h-3.5 w-3.5 shrink-0 text-red-400" />
2214
- <span className="min-w-0 flex-1">
2215
- {applyOutcomeUncertain && !displayedVerificationError
2216
- ? displayedStatusCheckError
2217
- : `Verification did not complete: ${displayedStatusCheckError}`}
2303
+ ) : (
2304
+ <Activity
2305
+ className="h-3.5 w-3.5 shrink-0"
2306
+ aria-hidden
2307
+ />
2308
+ )}
2309
+ {busy || requestPending ? (
2310
+ <>
2311
+ <span className="min-w-0 flex-1 truncate">
2312
+ {investigatingLabel.current}
2313
+ </span>
2314
+ {investigatingLabel.meta ? (
2315
+ <span className="shrink-0 tabular-nums text-theme-text-tertiary/80">
2316
+ {investigatingLabel.meta}
2317
+ </span>
2318
+ ) : null}
2319
+ </>
2320
+ ) : (
2321
+ <span>
2322
+ The agent did not provide a final assessment.
2323
+ </span>
2324
+ )}
2325
+ </div>
2326
+ )}
2327
+ {displayedStatusCheckError ? (
2328
+ <div className="mt-2 flex items-center gap-2 rounded-lg border border-red-500/30 bg-red-500/5 px-3 py-2 text-xs text-theme-text-secondary">
2329
+ <AlertTriangle className="h-3.5 w-3.5 shrink-0 text-red-400" />
2330
+ <span className="min-w-0 flex-1">
2331
+ {applyOutcomeUncertain &&
2332
+ !displayedVerificationError
2333
+ ? displayedStatusCheckError
2334
+ : `Verification did not complete: ${displayedStatusCheckError}`}
2335
+ </span>
2336
+ <button
2337
+ type="button"
2338
+ onClick={checkStatus}
2339
+ disabled={interactionsBlocked}
2340
+ className="shrink-0 rounded-md border border-theme-border px-2 py-1 font-medium text-theme-text-primary hover:bg-theme-hover disabled:opacity-50"
2341
+ >
2342
+ {applyOutcomeUncertain &&
2343
+ !displayedVerificationError
2344
+ ? "Check current status"
2345
+ : "Check current status again"}
2346
+ </button>
2347
+ </div>
2348
+ ) : null}
2349
+ </section>
2350
+
2351
+ {hasMultipleAssessments ? (
2352
+ <button
2353
+ type="button"
2354
+ data-investigation-earlier-assessments
2355
+ onClick={viewActivity}
2356
+ className="flex items-center gap-1.5 self-start rounded-md px-1.5 py-1 text-xs text-theme-text-secondary hover:bg-theme-hover hover:text-theme-text-primary"
2357
+ >
2358
+ <FileClock className="h-3.5 w-3.5" aria-hidden />
2359
+ {assessmentIndexes.length - 1 === 1
2360
+ ? "1 earlier assessment"
2361
+ : `${assessmentIndexes.length - 1} earlier assessments`}
2362
+ <span className="text-theme-text-tertiary">
2363
+ · in Activity
2218
2364
  </span>
2219
- <button
2220
- type="button"
2221
- onClick={checkStatus}
2222
- disabled={interactionsBlocked}
2223
- className="shrink-0 rounded-md border border-theme-border px-2 py-1 font-medium text-theme-text-primary hover:bg-theme-hover disabled:opacity-50"
2224
- >
2225
- {applyOutcomeUncertain && !displayedVerificationError
2226
- ? "Check current status"
2227
- : "Check current status again"}
2228
- </button>
2229
- </div>
2365
+ </button>
2230
2366
  ) : null}
2231
- </section>
2232
-
2233
- <AssessmentHistory
2234
- assessments={assessmentIndexes
2235
- .filter(
2236
- (index) =>
2237
- index !== currentAssessmentIdx &&
2238
- (index === initialAssessmentIdx ||
2239
- (turns[index].diagnosis?.remediation?.length ?? 0) >
2240
- 0 ||
2241
- turns.some(
2242
- (turn) =>
2243
- turn.explainAssessment ===
2244
- turns[index].resultSequence,
2245
- )),
2246
- )
2247
- .map((index) => ({
2248
- sequence: turns[index].resultSequence ?? index,
2249
- initial: index === initialAssessmentIdx,
2250
- diagnosis: turns[index].diagnosis!,
2251
- explanation: explanationFor(turns[index]),
2252
- sources: (() => {
2253
- // Each earlier assessment owns its own items; they
2254
- // render read-only here and never annotate cards.
2255
- const resolution =
2256
- resolveInvestigationRootCauseEvidence(
2257
- projection,
2258
- turns[index].diagnosis!.rootCauseEvidence,
2259
- index,
2260
- );
2261
- const earlierCase = resolveInvestigationCase(
2262
- projection,
2263
- turns[index].diagnosis,
2264
- index,
2265
- );
2266
- return resolution.links.length ||
2267
- earlierCase.items.length ? (
2268
- <AssessmentSources
2269
- renderedGroupIds={visibleEvidenceGroupIds}
2270
- resolution={resolution}
2271
- investigationCase={earlierCase}
2272
- readOnly
2273
- onViewSource={viewActivitySource}
2274
- />
2275
- ) : undefined;
2276
- })(),
2277
- }))}
2278
- />
2367
+ </div>
2279
2368
 
2280
2369
  <InvestigationEvidencePane
2281
2370
  projection={projection}
2282
2371
  rootCauseEvidence={paneResolution}
2283
- alsoSelectedGroupIds={followUpSelectedGroupIds}
2284
- investigationCase={paneCase}
2372
+ investigationCase={investigationCase}
2373
+ story={
2374
+ storyShape && currentAssessment?.diagnosis
2375
+ ? {
2376
+ report: currentAssessment.diagnosis.report,
2377
+ evidence: currentAssessment.diagnosis.evidence,
2378
+ }
2379
+ : undefined
2380
+ }
2381
+ storyShell={storyShell}
2285
2382
  collecting={
2286
2383
  explanationRequest?.status !== "running" &&
2287
2384
  (requestPending ||
@@ -2298,55 +2395,10 @@ export function InvestigationView({
2298
2395
  onOpenTimeline={stale ? undefined : onOpenTimeline}
2299
2396
  revealRequest={evidenceRevealRequest}
2300
2397
  onRevealReady={revealEvidenceSource}
2301
- afterEvidence={
2302
- hasNextSteps && currentAssessment?.diagnosis ? (
2303
- <section
2304
- ref={nextStepsRef}
2305
- tabIndex={-1}
2306
- aria-labelledby={`${workspaceId}-next-steps`}
2307
- className="investigation-next-steps rounded-xl border p-4 outline-none focus-visible:ring-2 focus-visible:ring-accent/50"
2308
- >
2309
- <h2
2310
- id={`${workspaceId}-next-steps`}
2311
- className="text-lg font-semibold text-theme-text-primary"
2312
- >
2313
- {earlierPlan
2314
- ? "Earlier proposed steps"
2315
- : "Next steps"}
2316
- </h2>
2317
- <ResultCard
2318
- diagnosis={currentAssessment.diagnosis}
2319
- section="actions"
2320
- compactActions
2321
- actionNotice={
2322
- earlierPlan
2323
- ? assessmentNeedsCurrentStateVerification
2324
- ? "Proposed before the apply attempt. Current state has not been verified."
2325
- : "Proposed before the latest evidence. Reassess before applying."
2326
- : undefined
2327
- }
2328
- onApply={
2329
- canOfferInvestigationApply({
2330
- currentAssessmentIdx,
2331
- lastRemediationIdx,
2332
- lastApplyAttemptIdx,
2333
- localApplyAttemptAssessmentIdx,
2334
- interactionsBlocked,
2335
- hosted,
2336
- hasNewerEvidence:
2337
- hasEvidenceCollectedAfterAssessment,
2338
- })
2339
- ? requestApply
2340
- : undefined
2341
- }
2342
- animate={currentAssessment.animateResult !== false}
2343
- showDisclaimer={false}
2344
- />
2345
- </section>
2346
- ) : undefined
2347
- }
2398
+ afterEvidence={nextStepsSection}
2399
+ recordNotes={recordNotes}
2348
2400
  />
2349
- </>
2401
+ </div>
2350
2402
  )}
2351
2403
  </div>
2352
2404
  </div>
@@ -2361,105 +2413,17 @@ export function InvestigationView({
2361
2413
  resourceLabel={formatInvestigationTarget(run)}
2362
2414
  context={run.context}
2363
2415
  fix={pendingFix}
2416
+ reason={currentAssessment?.diagnosis?.recommendedReason}
2417
+ precondition={
2418
+ currentAssessment?.diagnosis?.recommendedIndex
2419
+ ? currentAssessment.diagnosis.steps?.[
2420
+ currentAssessment.diagnosis.recommendedIndex - 1
2421
+ ]?.precondition
2422
+ : undefined
2423
+ }
2364
2424
  managedBy={run.managedBy}
2365
2425
  confidence={turns[lastRemediationIdx]?.diagnosis?.confidence}
2366
2426
  />
2367
2427
  </div>
2368
2428
  );
2369
2429
  }
2370
-
2371
- function AssessmentHistory({
2372
- assessments,
2373
- }: {
2374
- assessments: {
2375
- sequence: number;
2376
- diagnosis: Diagnosis;
2377
- explanation?: AssessmentExplanation;
2378
- initial: boolean;
2379
- sources?: ReactNode;
2380
- }[];
2381
- }) {
2382
- const reveal = useDisclosureReveal<HTMLDivElement>();
2383
- const { revealAfterToggle } = reveal;
2384
- const [open, setOpen] = useState(false);
2385
- const regionId = useId();
2386
- const openRequest = assessments.find((item) => item.explanation?.openRequest)
2387
- ?.explanation?.openRequest;
2388
- useEffect(() => {
2389
- if (openRequest) {
2390
- setOpen(true);
2391
- revealAfterToggle(true);
2392
- }
2393
- }, [openRequest, revealAfterToggle]);
2394
- if (assessments.length === 0) return null;
2395
- return (
2396
- <div data-investigation-assessment-history className="overflow-hidden">
2397
- <button
2398
- type="button"
2399
- aria-expanded={open}
2400
- aria-controls={regionId}
2401
- onClick={() => {
2402
- setOpen(!open);
2403
- reveal.revealAfterToggle(!open);
2404
- }}
2405
- className="flex items-center gap-2 rounded-md px-2 py-1 text-left text-xs text-theme-text-tertiary hover:bg-theme-hover hover:text-theme-text-secondary"
2406
- >
2407
- <CollapseChevron open={open} className="h-3.5 w-3.5" />
2408
- <span className="font-medium">
2409
- Previous assessments · {assessments.length}
2410
- </span>
2411
- </button>
2412
- <div id={regionId} ref={reveal.elementRef}>
2413
- <Collapse open={open}>
2414
- <div className="mt-2 space-y-4 border-l border-theme-border pl-3 pb-2">
2415
- {assessments.map(
2416
- (
2417
- { sequence, diagnosis, explanation, initial, sources },
2418
- index,
2419
- ) => (
2420
- <section
2421
- key={sequence}
2422
- aria-label={
2423
- initial
2424
- ? "Initial assessment"
2425
- : `Earlier assessment ${index + 1}`
2426
- }
2427
- >
2428
- <h3 className="text-sm font-medium text-theme-text-secondary">
2429
- {initial
2430
- ? "Initial assessment"
2431
- : `Earlier assessment ${index + 1}`}
2432
- </h3>
2433
- <ResultCard
2434
- diagnosis={diagnosis}
2435
- explanation={explanation}
2436
- assessmentSources={sources}
2437
- section="conclusion"
2438
- showDisclaimer={false}
2439
- />
2440
- {(diagnosis.remediation?.length ?? 0) > 0 && (
2441
- <section
2442
- aria-label="Earlier proposed steps"
2443
- className="mt-3 border-t border-theme-border/60 pt-3"
2444
- >
2445
- <h3 className="text-sm font-medium text-theme-text-primary">
2446
- Earlier proposed steps
2447
- </h3>
2448
- <ResultCard
2449
- diagnosis={diagnosis}
2450
- section="actions"
2451
- compactActions
2452
- actionNotice="From an earlier assessment, not the current recommendation."
2453
- showDisclaimer={false}
2454
- />
2455
- </section>
2456
- )}
2457
- </section>
2458
- ),
2459
- )}
2460
- </div>
2461
- </Collapse>
2462
- </div>
2463
- </div>
2464
- );
2465
- }