@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
@@ -5,6 +5,7 @@ import {
5
5
  useEffect,
6
6
  useId,
7
7
  useLayoutEffect,
8
+ useMemo,
8
9
  useRef,
9
10
  useState,
10
11
  type ReactNode,
@@ -31,6 +32,7 @@ import {
31
32
  DiffViewer,
32
33
  StatusDot,
33
34
  TerminalBlock,
35
+ TerminalBlockLabel,
34
36
  defaultConditionTone,
35
37
  displayKind,
36
38
  formatRelativeAgeTime,
@@ -49,6 +51,16 @@ import {
49
51
  type TimeSeries,
50
52
  } from "@skyhook-io/k8s-ui/components/charts";
51
53
  import { apiVersionToGroup } from "../../utils/navigation";
54
+ import {
55
+ AnalysisStory,
56
+ resolveStoryPlacements,
57
+ type StoryPlacementLoss,
58
+ type StoryPlacementTarget,
59
+ } from "./AnalysisStory";
60
+ import type {
61
+ DiagnosisEvidenceItem,
62
+ DiagnosisEvidenceSubject,
63
+ } from "../../api/diagnose";
52
64
  import { parseLogLine } from "../../utils/log-format";
53
65
  import {
54
66
  metricsChangeCoverage,
@@ -67,6 +79,7 @@ import {
67
79
  investigationSourceArgs,
68
80
  type InvestigationEvidenceData,
69
81
  type InvestigationEvidenceGroup,
82
+ type InvestigationResourceSummary,
70
83
  type InvestigationEvidenceObservation,
71
84
  type InvestigationEvidenceProjection,
72
85
  type InvestigationRootCauseEvidenceResolution,
@@ -88,6 +101,7 @@ import {
88
101
  investigationCaseObservationKey,
89
102
  type InvestigationCaseItem,
90
103
  type InvestigationCaseResolution,
104
+ sameKind,
91
105
  } from "./investigationCase";
92
106
  import type { DiagnosisEvidenceRole } from "../../api/diagnose";
93
107
 
@@ -197,16 +211,11 @@ export function partitionInvestigationEvidence(
197
211
  resolution?: InvestigationRootCauseEvidenceResolution,
198
212
  investigationCase?: InvestigationCaseResolution,
199
213
  /**
200
- * What a later turn cited, with the source that cited it. A follow-up adds
201
- * to what the displayed assessment selected and never takes a selection
202
- * away. The source travels with it because promoting a broader card needs
203
- * one: an id alone selects the group but leaves the broader gate below
204
- * unable to find who cited it, and the card stays withheld.
214
+ * Under a story every captured result about the target belongs in the one
215
+ * inventory the reader opens to see what Radar recorded; nothing about the
216
+ * target is folded into a second tier. Broader results stay cited-only.
205
217
  */
206
- alsoSelected?: readonly {
207
- groupId: string;
208
- source: InvestigationEvidenceSource;
209
- }[],
218
+ inventory = false,
210
219
  ) {
211
220
  // Selection: legacy root-cause links plus every placed agent item, of any
212
221
  // role (placement promotes a group into main the way a citation does; the
@@ -218,7 +227,6 @@ export function partitionInvestigationEvidence(
218
227
  ? resolution.links.map((link) => link.originalGroupId)
219
228
  : []),
220
229
  ...caseByGroup.keys(),
221
- ...(alsoSelected ?? []).map((entry) => entry.groupId),
222
230
  ]);
223
231
  const collections: Record<EvidenceCollection, InvestigationEvidenceGroup[]> =
224
232
  {
@@ -251,9 +259,7 @@ export function partitionInvestigationEvidence(
251
259
  if (broader && !namedByAgent) {
252
260
  const citingSource =
253
261
  resolution?.links.find((item) => item.originalGroupId === group.id)
254
- ?.source ??
255
- caseByGroup.get(group.id)?.[0]?.source ??
256
- alsoSelected?.find((entry) => entry.groupId === group.id)?.source;
262
+ ?.source ?? caseByGroup.get(group.id)?.[0]?.source;
257
263
  const focused = evidenceKindIsFocused(group.latest.data.type);
258
264
  const sourceGroups = citingSource
259
265
  ? groups.filter(
@@ -279,7 +285,8 @@ export function partitionInvestigationEvidence(
279
285
  !group.historical &&
280
286
  (selected.has(group.id) ||
281
287
  (!broader &&
282
- (group.latest.tier === "key" ||
288
+ (inventory ||
289
+ group.latest.tier === "key" ||
283
290
  (group.latest.tier === "supporting" && adverse(group)))));
284
291
  const collection = main
285
292
  ? "main"
@@ -358,8 +365,8 @@ export function investigationEvidenceRevealCollection(
358
365
  export function InvestigationEvidencePane({
359
366
  projection,
360
367
  rootCauseEvidence,
361
- alsoSelectedGroupIds,
362
368
  investigationCase,
369
+ story,
363
370
  collecting,
364
371
  animateGroupIds,
365
372
  onViewSource,
@@ -367,19 +374,24 @@ export function InvestigationEvidencePane({
367
374
  onOpenResource,
368
375
  onOpenTimeline,
369
376
  afterEvidence,
377
+ recordNotes,
378
+ storyShell = false,
370
379
  revealRequest,
371
380
  onRevealReady,
372
381
  }: {
373
382
  projection: InvestigationEvidenceProjection;
374
383
  /** Server-validated links for the current root cause; absent without one. */
375
384
  rootCauseEvidence?: InvestigationRootCauseEvidenceResolution;
376
- /** What a later turn cited; adds to the assessment's selection. */
377
- alsoSelectedGroupIds?: readonly {
378
- groupId: string;
379
- source: InvestigationEvidenceSource;
380
- }[];
381
385
  /** The current assessment's agent case, resolved against this projection. */
382
386
  investigationCase?: InvestigationCaseResolution;
387
+ /**
388
+ * The assessment's story: the agent's prose with `[[radar:evidence=N]]`
389
+ * placements resolved against `investigationCase` and the verdict's
390
+ * `evidence` array. Absent on backends and runs without the story contract,
391
+ * which keep the previous layout.
392
+ */
393
+ story?: { report: string; evidence?: DiagnosisEvidenceItem[] };
394
+ /** Open the story fully instead of the bounded preview — when the layout has room for it. */
383
395
  collecting: boolean;
384
396
  animateGroupIds: ReadonlySet<string>;
385
397
  onViewSource: (
@@ -392,8 +404,12 @@ export function InvestigationEvidencePane({
392
404
  onOpenResource?: (ref: DiagnosisResourceRef) => void;
393
405
  /** Opens Radar's Timeline filtered to the resource a changes card is about. */
394
406
  onOpenTimeline?: (scope: InvestigationTimelineScope) => void;
395
- /** Actions follow the complete evidence section, including its disclosures. */
407
+ /** Next steps. Under a story they precede the inventory of captured results; otherwise they follow the evidence section. */
396
408
  afterEvidence?: ReactNode;
409
+ /** The agent's notes that reached no card, shown inside Captured results. */
410
+ recordNotes?: ReactNode;
411
+ /** No verdict yet, but the story shape is coming: keep its cards in place. */
412
+ storyShell?: boolean;
397
413
  /** Explicit Activity → Findings navigation, including repeat clicks. */
398
414
  revealRequest?: { sourceId: string; requestId: number };
399
415
  onRevealReady?: (sourceId: string) => void;
@@ -413,13 +429,17 @@ export function InvestigationEvidencePane({
413
429
  const [coverageOpen, setCoverageOpen] = useState(false);
414
430
  const [workloadOpen, setWorkloadOpen] = useState(false);
415
431
  const [earlierOpen, setEarlierOpen] = useState(false);
432
+ // Null until the reader touches the fold: with nothing placed above it, a
433
+ // finished story opens Captured results by itself, and it stays theirs to close.
434
+ const [resultsOpen, setResultsOpen] = useState<boolean | null>(null);
416
435
  const handledRevealRequestRef = useRef<number | undefined>(undefined);
417
436
  const openingForRevealRequestRef = useRef<number | undefined>(undefined);
437
+ const storyMode = story !== undefined || storyShell;
418
438
  const partition = partitionInvestigationEvidence(
419
439
  projection.groups,
420
440
  rootCauseEvidence,
421
441
  investigationCase,
422
- alsoSelectedGroupIds,
442
+ storyMode,
423
443
  );
424
444
  const hasCurrentEvidence =
425
445
  partition.main.length + partition.workload.length > 0;
@@ -449,6 +469,10 @@ export function InvestigationEvidencePane({
449
469
  // nested "Previous observations" collection; open the way to it first.
450
470
  const collection = collectionByGroup.get(groupId);
451
471
  let settle = 0;
472
+ if (storyMode && !resultsOpen) {
473
+ setResultsOpen(true);
474
+ settle = investigationDisclosureSettleDelay(prefersReducedMotion());
475
+ }
452
476
  if (collection === "workload" || collection === "earlier") {
453
477
  setWorkloadOpen(true);
454
478
  settle = investigationDisclosureSettleDelay(prefersReducedMotion());
@@ -467,7 +491,7 @@ export function InvestigationEvidencePane({
467
491
  });
468
492
  }, settle);
469
493
  },
470
- [onGroupOpenChange, collectionByGroup],
494
+ [onGroupOpenChange, collectionByGroup, storyMode, resultsOpen],
471
495
  );
472
496
  const revealCollection = revealRequest
473
497
  ? investigationEvidenceRevealCollection(
@@ -501,6 +525,11 @@ export function InvestigationEvidencePane({
501
525
  setEarlierOpen(true);
502
526
  return;
503
527
  }
528
+ if (storyMode && revealCollection && !resultsOpen) {
529
+ openingForRevealRequestRef.current = requestId;
530
+ setResultsOpen(true);
531
+ return;
532
+ }
504
533
  if (revealCollection === "coverage" && !coverageOpen) {
505
534
  openingForRevealRequestRef.current = requestId;
506
535
  setCoverageOpen(true);
@@ -535,42 +564,311 @@ export function InvestigationEvidencePane({
535
564
  earlierOpen,
536
565
  coverageOpen,
537
566
  workloadOpen,
567
+ storyMode,
568
+ resultsOpen,
538
569
  ]);
539
570
 
540
571
  const coverageGroups = groupEvidenceCoverage(projection.limitations);
572
+ // A caveat belongs on the claim it bounds: the "does not cover" line shows
573
+ // under the cause card. A verdict with no cause card (healthy) keeps it on
574
+ // every placed card, since that is where its scope lives.
575
+ const storyGapRoles = useMemo(
576
+ () =>
577
+ investigationCase?.items.some(
578
+ (item) => item.role === "cause" && item.placement === "card",
579
+ )
580
+ ? new Set(["cause"])
581
+ : undefined,
582
+ [investigationCase],
583
+ );
541
584
  const limitationSummary = coverageGroups
542
585
  .map((group) => `${group.label}: ${group.summary}`)
543
586
  .join(" · ");
587
+ // Resolves a story placement to the agent item it names, or says why it
588
+ // cannot: an index Radar does not know, an item the server could not bind,
589
+ // or a bound item that matches no single observation. Only the last two
590
+ // distinguish "the agent was wrong" from "Radar could not tell".
591
+ const resolveStoryItem = useCallback(
592
+ (index: number): StoryPlacementTarget | StoryPlacementLoss => {
593
+ const evidence = story?.evidence ?? [];
594
+ if (!Number.isInteger(index) || index < 0 || index >= evidence.length)
595
+ return "invalid";
596
+ if (evidence[index]?.status === "unlinked") return "unlinked";
597
+ const item = investigationCase?.items.find(
598
+ (candidate) => candidate.index === index,
599
+ );
600
+ if (!item) return "unplaced";
601
+ if (item.groupId && item.observation)
602
+ return { item, domId: `story-${item.groupId}` };
603
+ // A subject-less citation of a call that fans out into several cards
604
+ // (the diagnose bundle above all) names the call, not one of its
605
+ // results. Placing the call's primary card is not guessing which claim
606
+ // the agent meant: it is rendering the headline result of what it
607
+ // cited, the same card Activity's "Show evidence" opens for that call.
608
+ if (item.subject === undefined && item.source.primaryGroupId) {
609
+ const group = projection.groups.find(
610
+ (candidate) => candidate.id === item.source.primaryGroupId,
611
+ );
612
+ // The card is the one the cited call itself produced. A later read of
613
+ // the same thing lives on the same group; substituting it would show
614
+ // newer evidence under an older claim.
615
+ const observation = group?.observations.find(
616
+ (candidate) => candidate.source.id === item.source.id,
617
+ );
618
+ if (group && observation)
619
+ return {
620
+ item: {
621
+ ...item,
622
+ groupId: group.id,
623
+ observation,
624
+ placement: observation === group.latest ? "card" : "revision",
625
+ },
626
+ domId: `story-${group.id}`,
627
+ };
628
+ }
629
+ // A bound call that produced no card at all (a search, a metrics
630
+ // table) is not ambiguous, it is unrendered; its raw result is in
631
+ // Activity and the note at the claim says so.
632
+ const hasCard = projection.groups.some((group) =>
633
+ group.observations.some(
634
+ (observation) => observation.source.id === item.source.id,
635
+ ),
636
+ );
637
+ return hasCard
638
+ ? "unplaced"
639
+ : { kind: "nocard", sourceId: item.source.id };
640
+ },
641
+ [story?.evidence, investigationCase, projection.groups],
642
+ );
643
+ const groupsById = new Map(
644
+ projection.groups.map((group) => [group.id, group] as const),
645
+ );
646
+ const renderStoryPlacement = useCallback(
647
+ (target: StoryPlacementTarget, { compact }: { compact: boolean }) => {
648
+ const group = target.item.groupId
649
+ ? groupsById.get(target.item.groupId)
650
+ : undefined;
651
+ if (!group || !target.item.observation) return null;
652
+ return (
653
+ <EvidenceCard
654
+ group={group}
655
+ observation={target.item.observation}
656
+ domId={target.domId}
657
+ animateArrival={false}
658
+ onViewSource={onViewSource}
659
+ spanFullRow
660
+ compact={compact}
661
+ noteMode="chip"
662
+ gapRoles={storyGapRoles}
663
+ />
664
+ );
665
+ },
666
+ // groupsById is rebuilt per render from projection.groups.
667
+ // eslint-disable-next-line react-hooks/exhaustive-deps
668
+ [projection.groups, onViewSource, storyGapRoles],
669
+ );
670
+ // A card behind the story's fold stays mounted but inert once the fold has
671
+ // been opened and closed; a reveal has to open the fold first, then land.
672
+ const [storyOpenRequest, setStoryOpenRequest] = useState(0);
673
+ // What the story actually placed, not what the case could have: a cited
674
+ // card the agent never placed is inventory, and the inventory says so.
675
+ // A marker written in the ledger's ref form names the item carrying that ref.
676
+ const storyEvidence = story?.evidence;
677
+ const resolveStoryRef = useCallback(
678
+ (ref: string): number | undefined => {
679
+ const matches = (storyEvidence ?? [])
680
+ .map((item, index) => (item.ref === ref ? index : -1))
681
+ .filter((index) => index >= 0);
682
+ // Two items on one ref name no single card; the renderer flags it.
683
+ return matches.length === 1 ? matches[0] : undefined;
684
+ },
685
+ [storyEvidence],
686
+ );
687
+ const storyReport = story?.report;
688
+ const storyPlacements = useMemo(
689
+ () =>
690
+ storyReport === undefined
691
+ ? undefined
692
+ : resolveStoryPlacements(
693
+ storyReport,
694
+ resolveStoryItem,
695
+ resolveStoryRef,
696
+ ),
697
+ [storyReport, resolveStoryItem, resolveStoryRef],
698
+ );
699
+ const placedCount = storyPlacements?.placedCount ?? 0;
700
+ // A card behind the fold may be mounted and inert, or not mounted at all
701
+ // before the fold first opens; either way the fold opens first and the
702
+ // card is found once it has settled.
703
+ const revealStoryTarget = useCallback(
704
+ (target: StoryPlacementTarget) => {
705
+ const land = (placed: HTMLElement) => {
706
+ placed.scrollIntoView({
707
+ block: "center",
708
+ behavior: prefersReducedMotion() ? "auto" : "smooth",
709
+ });
710
+ placed.focus({ preventScroll: true });
711
+ };
712
+ const placed = document.getElementById(target.domId);
713
+ // A twin's chip shares its sibling's card, so the card's position, not
714
+ // the item's own kind, says whether the story holds it.
715
+ const placedInStory =
716
+ storyPlacements?.placedAt.has(target.item.index) ?? false;
717
+ if (placed && !placed.closest("[inert]")) {
718
+ land(placed);
719
+ return;
720
+ }
721
+ if (placed || placedInStory) {
722
+ setStoryOpenRequest((n) => n + 1);
723
+ window.setTimeout(() => {
724
+ const settled = document.getElementById(target.domId);
725
+ if (settled) land(settled);
726
+ else revealCaseItem(target.item);
727
+ }, investigationDisclosureSettleDelay(prefersReducedMotion()));
728
+ return;
729
+ }
730
+ revealCaseItem(target.item);
731
+ },
732
+ [revealCaseItem, storyPlacements],
733
+ );
734
+ const resultsShown =
735
+ resultsOpen ?? (placedCount === 0 && !collecting && !!story);
736
+ const placedGroupIds = new Set(
737
+ [...(storyPlacements?.byIndex.values() ?? [])].flatMap((entry) =>
738
+ entry.kind === "placed" && entry.target.item.groupId
739
+ ? [entry.target.item.groupId]
740
+ : [],
741
+ ),
742
+ );
743
+ const capturedTotal =
744
+ partition.main.length +
745
+ partition.workload.length +
746
+ partition.earlier.length;
747
+ const resultsSummary = [
748
+ partition.hiddenBroader > 0
749
+ ? `${partition.hiddenBroader} about other resources not shown`
750
+ : undefined,
751
+ partition.hiddenMetrics > 0
752
+ ? `${partition.hiddenMetrics} broader metric ${partition.hiddenMetrics === 1 ? "result" : "results"} not shown`
753
+ : undefined,
754
+ projection.limitations.length > 0
755
+ ? `${projection.limitations.length} ${projection.limitations.length === 1 ? "check" : "checks"} limited`
756
+ : undefined,
757
+ ]
758
+ .filter((part): part is string => Boolean(part))
759
+ .join(" · ");
760
+ const mainCards = (
761
+ <>
762
+ <div className="grid items-start gap-2.5">
763
+ {partition.main.map((group) => (
764
+ <EvidenceCard
765
+ key={group.id}
766
+ group={group}
767
+ spanFullRow
768
+ animateArrival={animateGroupIds.has(group.id)}
769
+ onViewSource={onViewSource}
770
+ placedInStory={placedGroupIds.has(group.id)}
771
+ />
772
+ ))}
773
+ </div>
774
+
775
+ {partition.hiddenBroader > 0 ? (
776
+ <p
777
+ className="text-xs text-theme-text-tertiary"
778
+ data-testid="investigation-hidden-evidence"
779
+ >
780
+ {partition.hiddenBroader === 1
781
+ ? "1 result about another resource is not shown; it appears here when the assessment cites it."
782
+ : `${partition.hiddenBroader} results about other resources are not shown; they appear here when the assessment cites them.`}
783
+ </p>
784
+ ) : null}
785
+
786
+ {partition.hiddenMetrics > 0 ? (
787
+ <p
788
+ className="text-xs text-theme-text-tertiary"
789
+ data-testid="investigation-hidden-metrics"
790
+ >
791
+ {partition.hiddenMetrics === 1
792
+ ? "1 broader metric result is not shown; it appears here when the assessment cites it."
793
+ : `${partition.hiddenMetrics} broader metric results are not shown; they appear here when the assessment cites them.`}
794
+ </p>
795
+ ) : null}
796
+
797
+ {!hasCurrentEvidence ? (
798
+ <EmptyCollection
799
+ collecting={collecting}
800
+ hasEarlierEvidence={partition.earlier.length > 0}
801
+ onViewActivity={onViewActivity}
802
+ />
803
+ ) : null}
804
+
805
+ <CollapsedEvidenceCollection
806
+ id="investigation-workload-evidence"
807
+ title="More evidence about this resource"
808
+ description=""
809
+ groups={partition.workload}
810
+ totalCount={partition.workload.length + partition.earlier.length}
811
+ animateGroupIds={animateGroupIds}
812
+ onViewSource={onViewSource}
813
+ open={workloadOpen}
814
+ onOpenChange={setWorkloadOpen}
815
+ >
816
+ <CollapsedEvidenceCollection
817
+ id="investigation-earlier-evidence"
818
+ title="Previous observations"
819
+ description="Earlier does not mean resolved."
820
+ groups={partition.earlier}
821
+ animateGroupIds={animateGroupIds}
822
+ onViewSource={onViewSource}
823
+ open={earlierOpen}
824
+ onOpenChange={setEarlierOpen}
825
+ />
826
+ </CollapsedEvidenceCollection>
827
+ </>
828
+ );
544
829
  const content = (
545
830
  <section
546
831
  aria-labelledby="investigation-radar-evidence"
547
- className="investigation-evidence @container/evidence space-y-4 rounded-xl border p-4"
832
+ className={
833
+ // In the story shape this is no card: the analysis, the steps and the
834
+ // record are siblings. The element stays only as the container the
835
+ // cards' queries measure.
836
+ storyMode
837
+ ? "@container/evidence space-y-4"
838
+ : "investigation-evidence @container/evidence space-y-3 rounded-xl border p-3"
839
+ }
548
840
  >
549
841
  <span className="sr-only" role="status" aria-live="polite">
550
842
  {projection.limitations.length > 0
551
843
  ? `Evidence coverage update: ${limitationSummary}`
552
844
  : ""}
553
845
  </span>
554
- <div className="flex min-w-0 items-start justify-between gap-3">
555
- <div className="min-w-0">
556
- <div className="flex items-center gap-2">
557
- <h2
558
- id="investigation-radar-evidence"
559
- className="text-lg font-semibold text-theme-text-primary"
560
- >
561
- Evidence
562
- </h2>
563
- {collecting ? (
564
- <span className="inline-flex items-center gap-1.5 text-xs text-accent-text">
565
- <span className="h-1.5 w-1.5 animate-pulse rounded-full bg-accent" />
566
- collecting
567
- </span>
568
- ) : null}
846
+ {!storyMode ? (
847
+ <div className="flex min-w-0 items-start justify-between gap-3">
848
+ <div className="min-w-0">
849
+ <div className="flex items-center gap-2">
850
+ <h2
851
+ id="investigation-radar-evidence"
852
+ className="text-lg font-semibold text-theme-text-primary"
853
+ >
854
+ Evidence
855
+ </h2>
856
+ {collecting ? (
857
+ <span className="inline-flex items-center gap-1.5 text-xs text-accent-text">
858
+ <span className="h-1.5 w-1.5 animate-pulse rounded-full bg-accent" />
859
+ collecting
860
+ </span>
861
+ ) : null}
862
+ </div>
569
863
  </div>
570
864
  </div>
571
- </div>
865
+ ) : (
866
+ <h2 id="investigation-radar-evidence" className="sr-only">
867
+ Analysis and evidence
868
+ </h2>
869
+ )}
572
870
 
573
- {projection.limitations.length > 0 ? (
871
+ {projection.limitations.length > 0 && !storyMode ? (
574
872
  <CoverageStrip
575
873
  groups={coverageGroups}
576
874
  visibleGroupIds={new Set(partition.collectionByGroup.keys())}
@@ -589,77 +887,103 @@ export function InvestigationEvidencePane({
589
887
  />
590
888
  ) : null}
591
889
 
592
- {/* What the agent considered and rejected belongs with the conclusion
593
- it argues for, not after the evidence list among the withheld
594
- counts, where it read as bookkeeping. */}
595
- {visibleRuledOut.length > 0 ? (
596
- <RuledOutBlock entries={visibleRuledOut} onReveal={revealCaseItem} />
597
- ) : null}
598
-
599
- <div className="grid items-start gap-2.5">
600
- {partition.main.map((group) => (
601
- <EvidenceCard
602
- key={group.id}
603
- group={group}
604
- spanFullRow
605
- animateArrival={animateGroupIds.has(group.id)}
606
- onViewSource={onViewSource}
607
- />
608
- ))}
609
- </div>
610
-
611
- {partition.hiddenBroader > 0 ? (
612
- <p
613
- className="text-xs text-theme-text-tertiary"
614
- data-testid="investigation-hidden-evidence"
890
+ {storyMode ? (
891
+ <div
892
+ data-story-card
893
+ data-story-shell={story ? undefined : ""}
894
+ className="investigation-evidence rounded-xl border p-4"
615
895
  >
616
- {partition.hiddenBroader === 1
617
- ? "1 result about another resource is not shown; it appears here when the assessment cites it."
618
- : `${partition.hiddenBroader} results about other resources are not shown; they appear here when the assessment cites them.`}
619
- </p>
896
+ <div className="mb-2 flex items-center gap-2 text-xs font-semibold text-theme-text-secondary">
897
+ Why
898
+ <span className="font-normal text-theme-text-tertiary">
899
+ · Agent analysis
900
+ </span>
901
+ {collecting ? (
902
+ <span className="ml-auto inline-flex items-center gap-1.5 text-xs font-normal text-accent-text">
903
+ <span className="h-1.5 w-1.5 animate-pulse rounded-full bg-accent" />
904
+ collecting
905
+ </span>
906
+ ) : null}
907
+ </div>
908
+ {story ? (
909
+ <AnalysisStory
910
+ report={story.report}
911
+ resolveItem={resolveStoryItem}
912
+ resolveRef={resolveStoryRef}
913
+ trailing={
914
+ visibleRuledOut.length > 0 ? (
915
+ <RuledOutBlock
916
+ entries={visibleRuledOut}
917
+ onReveal={revealCaseItem}
918
+ />
919
+ ) : undefined
920
+ }
921
+ renderPlacement={renderStoryPlacement}
922
+ onReveal={revealStoryTarget}
923
+ openRequest={storyOpenRequest}
924
+ onViewSource={(sourceId) => onViewSource(sourceId)}
925
+ />
926
+ ) : (
927
+ <p className="text-sm text-theme-text-tertiary">
928
+ The analysis appears when the investigation finishes.
929
+ </p>
930
+ )}
931
+ </div>
620
932
  ) : null}
621
933
 
622
- {partition.hiddenMetrics > 0 ? (
623
- <p
624
- className="text-xs text-theme-text-tertiary"
625
- data-testid="investigation-hidden-metrics"
626
- >
627
- {partition.hiddenMetrics === 1
628
- ? "1 broader metric result is not shown; it appears here when the assessment cites it."
629
- : `${partition.hiddenMetrics} broader metric results are not shown; they appear here when the assessment cites them.`}
630
- </p>
631
- ) : null}
934
+ {storyMode ? afterEvidence : null}
632
935
 
633
- {!hasCurrentEvidence ? (
634
- <EmptyCollection
635
- collecting={collecting}
636
- hasEarlierEvidence={partition.earlier.length > 0}
637
- onViewActivity={onViewActivity}
638
- />
936
+ {!storyMode && visibleRuledOut.length > 0 ? (
937
+ <RuledOutBlock entries={visibleRuledOut} onReveal={revealCaseItem} />
639
938
  ) : null}
640
939
 
641
- <CollapsedEvidenceCollection
642
- id="investigation-workload-evidence"
643
- title="More evidence about this workload"
644
- description=""
645
- groups={partition.workload}
646
- totalCount={partition.workload.length + partition.earlier.length}
647
- animateGroupIds={animateGroupIds}
648
- onViewSource={onViewSource}
649
- open={workloadOpen}
650
- onOpenChange={setWorkloadOpen}
651
- >
940
+ {storyMode ? (
652
941
  <CollapsedEvidenceCollection
653
- id="investigation-earlier-evidence"
654
- title="Previous observations"
655
- description="Earlier does not mean resolved."
656
- groups={partition.earlier}
942
+ id="investigation-captured-results"
943
+ title="Captured results"
944
+ description={[
945
+ placedCount > 0
946
+ ? `${placedCount} shown above`
947
+ : collecting
948
+ ? `${capturedTotal} collected so far`
949
+ : "None shown above",
950
+ resultsSummary,
951
+ ]
952
+ .filter(Boolean)
953
+ .join(" · ")}
954
+ groups={[]}
955
+ totalCount={capturedTotal}
956
+ keepWhenEmpty
657
957
  animateGroupIds={animateGroupIds}
658
958
  onViewSource={onViewSource}
659
- open={earlierOpen}
660
- onOpenChange={setEarlierOpen}
661
- />
662
- </CollapsedEvidenceCollection>
959
+ open={resultsShown}
960
+ onOpenChange={setResultsOpen}
961
+ >
962
+ <div className="space-y-4">
963
+ {projection.limitations.length > 0 ? (
964
+ <CoverageStrip
965
+ groups={coverageGroups}
966
+ visibleGroupIds={new Set(partition.collectionByGroup.keys())}
967
+ summary={limitationSummary}
968
+ onViewSource={onViewSource}
969
+ open={coverageOpen}
970
+ onOpenChange={setCoverageOpen}
971
+ />
972
+ ) : null}
973
+ {mainCards}
974
+ {recordNotes ? (
975
+ <div data-record-notes>
976
+ <div className="mb-1 text-xs font-semibold text-theme-text-secondary">
977
+ Notes without a card
978
+ </div>
979
+ {recordNotes}
980
+ </div>
981
+ ) : null}
982
+ </div>
983
+ </CollapsedEvidenceCollection>
984
+ ) : (
985
+ mainCards
986
+ )}
663
987
  </div>
664
988
  </section>
665
989
  );
@@ -704,7 +1028,7 @@ export function InvestigationEvidencePane({
704
1028
  }}
705
1029
  >
706
1030
  {content}
707
- {afterEvidence}
1031
+ {storyMode ? null : afterEvidence}
708
1032
  </EvidenceNavigationContext.Provider>
709
1033
  );
710
1034
  }
@@ -732,7 +1056,7 @@ function RuledOutBlock({
732
1056
  id={headingId}
733
1057
  className="flex items-center gap-2 text-xs font-semibold text-theme-text-secondary"
734
1058
  >
735
- Ruled out
1059
+ Also checked
736
1060
  <span className="text-[10px] font-semibold uppercase tracking-wide text-accent-text">
737
1061
  Agent
738
1062
  </span>
@@ -856,6 +1180,7 @@ function CollapsedEvidenceCollection({
856
1180
  open,
857
1181
  onOpenChange,
858
1182
  totalCount = groups.length,
1183
+ keepWhenEmpty = false,
859
1184
  children,
860
1185
  }: {
861
1186
  id: string;
@@ -870,10 +1195,12 @@ function CollapsedEvidenceCollection({
870
1195
  open: boolean;
871
1196
  onOpenChange: (open: boolean) => void;
872
1197
  totalCount?: number;
1198
+ /** Render at zero: the children carry the empty state and the withheld-results note. */
1199
+ keepWhenEmpty?: boolean;
873
1200
  children?: ReactNode;
874
1201
  }) {
875
1202
  const { elementRef, revealAfterToggle } = useDisclosureReveal<HTMLElement>();
876
- if (totalCount === 0) return null;
1203
+ if (totalCount === 0 && !keepWhenEmpty) return null;
877
1204
  const fullRowFlags = investigationEvidenceFullRowFlags(
878
1205
  groups.map((group) => group.latest.data.type),
879
1206
  );
@@ -1106,7 +1433,7 @@ function CoverageGroupRow({
1106
1433
  </p>
1107
1434
  {single.sources.at(-1) ? (
1108
1435
  <SourceButton
1109
- ariaLabel={`View source for ${group.label}`}
1436
+ ariaLabel={`View result for ${group.label}`}
1110
1437
  buttonLabel={
1111
1438
  single.sources.length > 1 ? "View latest in Activity" : undefined
1112
1439
  }
@@ -1186,7 +1513,7 @@ function CoverageGroupRow({
1186
1513
  </p>
1187
1514
  {limitation.sources.at(-1) ? (
1188
1515
  <SourceButton
1189
- ariaLabel={`View source for ${limitation.source}`}
1516
+ ariaLabel={`View result for ${limitation.source}`}
1190
1517
  buttonLabel={
1191
1518
  limitation.sources.length > 1
1192
1519
  ? "View latest in Activity"
@@ -1259,13 +1586,24 @@ export function investigationEvidenceShouldRevealHistory(
1259
1586
 
1260
1587
  function EvidenceCard({
1261
1588
  group,
1589
+ observation: observationOverride,
1262
1590
  domId = group.id,
1263
1591
  animateArrival,
1264
1592
  onViewSource,
1265
1593
  spanFullRow = false,
1266
1594
  prominence = "primary",
1595
+ compact = false,
1596
+ placedInStory = false,
1597
+ gapRoles,
1598
+ noteMode = "full",
1267
1599
  }: {
1268
1600
  group: InvestigationEvidenceGroup;
1601
+ /**
1602
+ * Render this exact observation instead of the group's current one — a
1603
+ * story places the read the agent cited, which may since have been
1604
+ * superseded, and says so rather than swapping in today's.
1605
+ */
1606
+ observation?: InvestigationEvidenceObservation;
1269
1607
  /** Stable layout/scroll identity when an existing card changes section. */
1270
1608
  domId?: string;
1271
1609
  animateArrival: boolean;
@@ -1276,6 +1614,21 @@ function EvidenceCard({
1276
1614
  /** Fill both columns when this card has no compact row partner. */
1277
1615
  spanFullRow?: boolean;
1278
1616
  prominence?: "primary" | "supporting" | "secondary";
1617
+ /**
1618
+ * Header and summary only, so the collapsed story preview never slices a
1619
+ * card. A story log card keeps its cited lines: they are the card.
1620
+ */
1621
+ compact?: boolean;
1622
+ /** This card also appears inside the story above. */
1623
+ placedInStory?: boolean;
1624
+ /** Roles whose "does not cover" line is shown; undefined shows every gap. */
1625
+ gapRoles?: ReadonlySet<string>;
1626
+ /**
1627
+ * Inside the story the prose above the card is the agent's reading of it,
1628
+ * so the note row keeps only the role chip (and an excluded hypothesis);
1629
+ * repeating the sentence under the card doubled the text for nothing.
1630
+ */
1631
+ noteMode?: "full" | "chip";
1279
1632
  }) {
1280
1633
  const {
1281
1634
  onOpenResource,
@@ -1295,18 +1648,52 @@ function EvidenceCard({
1295
1648
  [onGroupOpenChange, group.id],
1296
1649
  );
1297
1650
  const { elementRef, revealAfterToggle } = useDisclosureReveal<HTMLElement>();
1298
- const observation = group.latest;
1651
+ const observation = observationOverride ?? group.latest;
1652
+ const supersededRead =
1653
+ observationOverride !== undefined && observationOverride !== group.latest;
1299
1654
  const displaySummary =
1300
1655
  observation.data.type === "crash" && observation.summary
1301
1656
  ? parseLogLine(observation.summary).content
1302
1657
  : observation.summary;
1303
1658
  const citedOrder = citedOrderByGroup?.get(group.id);
1304
1659
  const caseItems = caseByGroup?.get(group.id) ?? [];
1305
- const cardItems = caseItems.filter((item) => item.placement === "card");
1660
+ // Annotations belong to the observation on screen: a card rendering an
1661
+ // earlier read shows the note the agent attached to that read, not the
1662
+ // latest one's.
1663
+ const cardItems = caseItems.filter((item) =>
1664
+ observationOverride
1665
+ ? item.observation !== undefined &&
1666
+ item.observation.source.id === observation.source.id &&
1667
+ item.observation.revision === observation.revision
1668
+ : item.placement === "card",
1669
+ );
1306
1670
  const revisionItems = caseItems.filter(
1307
1671
  (item) => item.placement === "revision",
1308
1672
  );
1309
1673
  const agentCause = cardItems.some((item) => item.role === "cause");
1674
+ // A card placed in the story: the prose carries the claim, so the card
1675
+ // shows its role beside the title, its scope line if any, and for a log
1676
+ // stream the decisive lines themselves; the rest waits behind the chevron.
1677
+ const storyCard = noteMode === "chip";
1678
+ const storyGaps = storyCard
1679
+ ? cardItems
1680
+ .map((item) =>
1681
+ item.gap && (!gapRoles || gapRoles.has(item.role)) ? item.gap : "",
1682
+ )
1683
+ .filter(Boolean)
1684
+ : [];
1685
+ // A compact card keeps its single row; the agent's clause on what the
1686
+ // result does not cover rides on it after the status, cut to the row, whole
1687
+ // on hover. The clause is written to stand alone, so it carries no label.
1688
+ const gapOnRow = compact && storyGaps.length > 0;
1689
+ // What a rules_out card excludes is the point of placing it; it stays on
1690
+ // the story card as its one line.
1691
+ const storyExcludes = storyCard
1692
+ ? cardItems
1693
+ .filter((item) => item.role === "rules_out")
1694
+ .map((item) => excludedByItem?.get(investigationCaseItemKey(item)))
1695
+ .filter((entry): entry is string => Boolean(entry))
1696
+ : [];
1310
1697
  const previousObservations = previousDifferentObservations(
1311
1698
  group,
1312
1699
  citedOrder,
@@ -1325,7 +1712,41 @@ function EvidenceCard({
1325
1712
  observation.data,
1326
1713
  observation.summary,
1327
1714
  );
1328
- const canExpand = hasEvidenceDetails || meaningfulHistory;
1715
+ const storyLogLines =
1716
+ storyCard && observation.data.type === "logs"
1717
+ ? (observation.data.logs?.lines ?? [])
1718
+ .slice(-VISIBLE_LOG_EVIDENCE_LINES)
1719
+ .map((line) => stripAnsi(line))
1720
+ : [];
1721
+ // A story log card shows the newest two selected lines always — the ones a
1722
+ // story cites from a tail. Expanding appends the earlier lines below them,
1723
+ // under a divider, so nothing above them moves.
1724
+ const storyLogHead = storyLogLines.slice(-2);
1725
+ const storyLogRest = storyLogLines.slice(0, -2);
1726
+ // A story log card carries its cited lines inline, so the only thing left to
1727
+ // expand is the earlier lines; the record keeps the full logs body.
1728
+ const inlineLogs = storyCard && observation.data.type === "logs";
1729
+ // A chart placed as the cause or the symptom is the card: it shows inline,
1730
+ // and the fold has nothing left to add.
1731
+ const inlineMetrics =
1732
+ storyCard &&
1733
+ !compact &&
1734
+ observation.data.type === "metrics" &&
1735
+ cardItems.some((item) => item.role === "cause" || item.role === "symptom");
1736
+ // Each row a citation names is the part of the listing the sentence rests
1737
+ // on; an entry the listing does not hold is often the point, so the card
1738
+ // says so rather than showing another row.
1739
+ const storyInventoryRows =
1740
+ storyCard && !compact && observation.data.type === "inventory"
1741
+ ? namedInventoryRows(
1742
+ observation.data.resources,
1743
+ cardItems,
1744
+ listingScopeNamespace(observation.source),
1745
+ )
1746
+ : [];
1747
+ const hasExpandableBody =
1748
+ (hasEvidenceDetails && !inlineLogs && !inlineMetrics) || meaningfulHistory;
1749
+ const canExpand = !compact && (hasExpandableBody || storyLogLines.length > 2);
1329
1750
  const revealHistory = investigationEvidenceShouldRevealHistory(
1330
1751
  group,
1331
1752
  revealSourceId,
@@ -1361,6 +1782,11 @@ function EvidenceCard({
1361
1782
  <span className="text-sm font-semibold leading-snug text-theme-text-primary">
1362
1783
  {observation.title}
1363
1784
  </span>
1785
+ {storyCard
1786
+ ? [...new Set(cardItems.map((item) => item.role))].map((role) =>
1787
+ role ? <AgentRoleChip key={role} role={role} /> : null,
1788
+ )
1789
+ : null}
1364
1790
  </span>
1365
1791
  {observation.relevance === "broader" &&
1366
1792
  resourceRef &&
@@ -1370,7 +1796,20 @@ function EvidenceCard({
1370
1796
  {resourceIdentity} · {resourceRef.kind}
1371
1797
  </span>
1372
1798
  ) : null}
1373
- {displaySummary ? (
1799
+ {gapOnRow ? (
1800
+ <Tooltip
1801
+ content={storyGaps.join(" · ")}
1802
+ wrapperClassName="mt-0.5 w-full min-w-0"
1803
+ >
1804
+ <span className="min-w-0 flex-1 truncate text-xs leading-relaxed text-theme-text-secondary">
1805
+ {displaySummary}
1806
+ {displaySummary ? " · " : null}
1807
+ <span data-agent-gap-hint className="text-theme-text-tertiary">
1808
+ {storyGaps.join(" · ")}
1809
+ </span>
1810
+ </span>
1811
+ </Tooltip>
1812
+ ) : displaySummary ? (
1374
1813
  <span
1375
1814
  className={clsx(
1376
1815
  "mt-0.5 block text-xs leading-relaxed text-theme-text-secondary",
@@ -1381,7 +1820,16 @@ function EvidenceCard({
1381
1820
  {displaySummary}
1382
1821
  </span>
1383
1822
  ) : null}
1384
- {group.historical ? (
1823
+ {supersededRead ? (
1824
+ <span className="mt-0.5 block text-xs text-theme-text-tertiary">
1825
+ Captured in turn {observation.source.turnIndex + 1} · a newer read
1826
+ of this exists in Captured results
1827
+ </span>
1828
+ ) : placedInStory ? (
1829
+ <span className="mt-0.5 block text-xs text-theme-text-tertiary">
1830
+ In the analysis
1831
+ </span>
1832
+ ) : group.historical ? (
1385
1833
  <span className="mt-0.5 block text-xs text-theme-text-tertiary">
1386
1834
  Previous observation · not confirmed by the latest check
1387
1835
  </span>
@@ -1468,7 +1916,7 @@ function EvidenceCard({
1468
1916
  <EvidenceCaveat data={observation.data} />
1469
1917
  ) : null}
1470
1918
  <SourceButton
1471
- ariaLabel={`View source for ${observation.title}`}
1919
+ ariaLabel={`View result for ${observation.title}`}
1472
1920
  compact
1473
1921
  onClick={() =>
1474
1922
  onViewSource(
@@ -1490,7 +1938,104 @@ function EvidenceCard({
1490
1938
  ) : null}
1491
1939
  </div>
1492
1940
  </div>
1493
- {cardItems.some((item) => item.claim || item.role) ? (
1941
+ {storyCard ? (
1942
+ <>
1943
+ {(storyGaps.length > 0 || storyExcludes.length > 0) && !compact ? (
1944
+ <div
1945
+ className={clsx(
1946
+ "space-y-0.5 text-[11px] text-theme-text-tertiary",
1947
+ prominence === "primary" ? "px-3 pb-2.5" : "px-2.5 pb-2",
1948
+ )}
1949
+ >
1950
+ {storyExcludes.map((hypothesis) => (
1951
+ <p key={hypothesis} data-agent-excludes>
1952
+ Rules out: {hypothesis}
1953
+ </p>
1954
+ ))}
1955
+ {storyGaps.map((gap) => (
1956
+ <p key={gap} data-agent-gap>
1957
+ {gap}
1958
+ </p>
1959
+ ))}
1960
+ </div>
1961
+ ) : null}
1962
+ {storyLogHead.length > 0 ? (
1963
+ <div
1964
+ data-story-log-lines
1965
+ className={
1966
+ prominence === "primary" ? "px-3 pb-2.5" : "px-2.5 pb-2"
1967
+ }
1968
+ >
1969
+ <TerminalBlock
1970
+ footer={
1971
+ storyLogRest.length > 0 ? (
1972
+ <Collapse open={open && canExpand}>
1973
+ <TerminalBlockLabel divider>
1974
+ Earlier lines
1975
+ </TerminalBlockLabel>
1976
+ <pre className="overflow-x-auto whitespace-pre-wrap break-words px-3 pb-2.5 font-mono text-xs leading-relaxed text-[var(--terminal-text)]">
1977
+ {storyLogRest.join("\n")}
1978
+ </pre>
1979
+ </Collapse>
1980
+ ) : null
1981
+ }
1982
+ >
1983
+ {storyLogHead.join("\n")}
1984
+ </TerminalBlock>
1985
+ </div>
1986
+ ) : null}
1987
+ {storyInventoryRows.length > 0 ? (
1988
+ <div
1989
+ data-story-inventory-rows
1990
+ className={
1991
+ prominence === "primary" ? "px-3 pb-2.5" : "px-2.5 pb-2"
1992
+ }
1993
+ >
1994
+ <div className="rounded-md border border-theme-border">
1995
+ {storyInventoryRows.map((entry, index) =>
1996
+ entry.row ? (
1997
+ <InventoryRow
1998
+ key={entry.key}
1999
+ resource={entry.row}
2000
+ divider={index > 0}
2001
+ />
2002
+ ) : (
2003
+ <p
2004
+ key={entry.key}
2005
+ data-story-inventory-absent={
2006
+ entry.matches === 0 ? "" : undefined
2007
+ }
2008
+ className={clsx(
2009
+ "px-2.5 py-1.5 font-mono text-xs text-theme-text-secondary",
2010
+ index > 0 && "border-t border-theme-border/60",
2011
+ )}
2012
+ >
2013
+ {entry.matches === 0
2014
+ ? `Not in this listing: ${entry.label}`
2015
+ : `${entry.label}: ${entry.matches} entries match in this listing`}
2016
+ </p>
2017
+ ),
2018
+ )}
2019
+ </div>
2020
+ </div>
2021
+ ) : null}
2022
+ {inlineMetrics && observation.data.type === "metrics" ? (
2023
+ <div
2024
+ data-story-metrics
2025
+ className={
2026
+ prominence === "primary" ? "px-3 pb-2.5" : "px-2.5 pb-2"
2027
+ }
2028
+ >
2029
+ <MetricsBody
2030
+ data={observation.data}
2031
+ changeCoverage={metricsMarkersBySource?.get(
2032
+ observation.source.id,
2033
+ )}
2034
+ />
2035
+ </div>
2036
+ ) : null}
2037
+ </>
2038
+ ) : cardItems.some((item) => item.claim || item.role) ? (
1494
2039
  <div
1495
2040
  className={clsx(
1496
2041
  "space-y-1.5",
@@ -1500,15 +2045,20 @@ function EvidenceCard({
1500
2045
  {cardItems.map((item) => (
1501
2046
  <AgentClaimNote
1502
2047
  key={item.index}
1503
- claim={item.claim}
2048
+ claim={compact ? "" : item.claim}
1504
2049
  role={item.role}
2050
+ gap={
2051
+ compact || (gapRoles && !gapRoles.has(item.role))
2052
+ ? undefined
2053
+ : item.gap
2054
+ }
1505
2055
  excludes={excludedByItem?.get(investigationCaseItemKey(item))}
1506
2056
  className="pt-1.5"
1507
2057
  />
1508
2058
  ))}
1509
2059
  </div>
1510
2060
  ) : null}
1511
- {canExpand ? (
2061
+ {canExpand && hasExpandableBody ? (
1512
2062
  <div id={bodyId}>
1513
2063
  <Collapse open={open}>
1514
2064
  <div
@@ -1517,9 +2067,10 @@ function EvidenceCard({
1517
2067
  prominence === "primary" ? "px-3 py-3" : "px-2.5 py-2.5",
1518
2068
  )}
1519
2069
  >
1520
- {hasEvidenceDetails ? (
2070
+ {hasEvidenceDetails && !inlineLogs && !inlineMetrics ? (
1521
2071
  <EvidenceBody
1522
2072
  data={observation.data}
2073
+ condensed={storyCard}
1523
2074
  cardSummary={observation.summary}
1524
2075
  changeCoverage={metricsMarkersBySource?.get(
1525
2076
  observation.source.id,
@@ -1633,11 +2184,14 @@ function EvidenceBody({
1633
2184
  data,
1634
2185
  cardSummary,
1635
2186
  changeCoverage,
2187
+ condensed = false,
1636
2188
  }: {
1637
2189
  data: InvestigationEvidenceData;
1638
2190
  cardSummary?: string;
1639
2191
  /** Change markers for a metrics chart; derived by the pane, never by data. */
1640
2192
  changeCoverage?: MetricsChangeCoverage;
2193
+ /** The card's title already names the stream: skip the repeated badges. */
2194
+ condensed?: boolean;
1641
2195
  }) {
1642
2196
  switch (data.type) {
1643
2197
  case "issue":
@@ -1649,7 +2203,7 @@ function EvidenceBody({
1649
2203
  case "resource":
1650
2204
  return <ResourceBody data={data} />;
1651
2205
  case "logs":
1652
- return <LogsBody data={data} />;
2206
+ return <LogsBody data={data} condensed={condensed} />;
1653
2207
  case "events":
1654
2208
  return <EventsBody data={data} />;
1655
2209
  case "changes":
@@ -2089,7 +2643,13 @@ function conditionStatusTone(condition: {
2089
2643
  }
2090
2644
  }
2091
2645
 
2092
- function LogsBody({ data }: { data: EvidenceDataOf<"logs"> }) {
2646
+ function LogsBody({
2647
+ data,
2648
+ condensed = false,
2649
+ }: {
2650
+ data: EvidenceDataOf<"logs">;
2651
+ condensed?: boolean;
2652
+ }) {
2093
2653
  const lines = data.logs?.lines ?? [];
2094
2654
  const visibleLines = lines
2095
2655
  .slice(-VISIBLE_LOG_EVIDENCE_LINES)
@@ -2098,25 +2658,35 @@ function LogsBody({ data }: { data: EvidenceDataOf<"logs"> }) {
2098
2658
  return (
2099
2659
  <div className="space-y-2">
2100
2660
  <div className="flex flex-wrap items-center gap-1.5">
2101
- <Badge tone="structural" size="sm">
2102
- {data.pod} / {data.container}
2103
- </Badge>
2104
- <Badge severity="neutral" size="sm">
2105
- {data.previous ? "previous instance" : "current instance"}
2106
- </Badge>
2661
+ {condensed ? null : (
2662
+ <>
2663
+ <Badge tone="structural" size="sm">
2664
+ {data.pod} / {data.container}
2665
+ </Badge>
2666
+ <Badge severity="neutral" size="sm">
2667
+ {data.previous ? "previous instance" : "current instance"}
2668
+ </Badge>
2669
+ </>
2670
+ )}
2107
2671
  {data.logs?.fallback ? (
2108
- <Badge severity="warning" size="sm">
2109
- Unfiltered log tail
2672
+ <Badge severity="neutral" size="sm">
2673
+ Log tail · filter matched nothing
2110
2674
  </Badge>
2111
2675
  ) : null}
2112
2676
  {data.logs ? (
2113
2677
  <span className="text-xs text-theme-text-tertiary">
2114
- {data.logs.matchedLines} matching lines · {data.logs.totalLines}{" "}
2115
- processed from the requested log tail
2678
+ {data.logs.matchedLines} of {data.logs.totalLines} read lines
2679
+ matched the filter
2116
2680
  </span>
2117
2681
  ) : null}
2118
2682
  </div>
2119
- {visibleLines.length > 0 ? (
2683
+ {condensed ? (
2684
+ omittedLines > 0 ? (
2685
+ <p className="text-xs text-theme-text-tertiary">
2686
+ Showing the last {visibleLines.length} of {lines.length} lines.
2687
+ </p>
2688
+ ) : null
2689
+ ) : visibleLines.length > 0 ? (
2120
2690
  <TerminalBlock
2121
2691
  label={
2122
2692
  omittedLines > 0
@@ -2754,46 +3324,117 @@ function MetricsBody({
2754
3324
  );
2755
3325
  }
2756
3326
 
3327
+ // The rows the citations on a card name, in citation order: one row when the
3328
+ // name picks exactly one entry, otherwise a line saying the entry is absent
3329
+ // or which entries the name could mean.
3330
+ function listingScopeNamespace(
3331
+ source: InvestigationEvidenceSource,
3332
+ ): string | undefined {
3333
+ const namespace = investigationSourceArgs(source)?.namespace;
3334
+ return typeof namespace === "string" && namespace ? namespace : undefined;
3335
+ }
3336
+
3337
+ export function namedInventoryRows(
3338
+ resources: InvestigationResourceSummary[],
3339
+ items: readonly { subject?: DiagnosisEvidenceSubject }[],
3340
+ /** The namespace the listing call was scoped to; rows omit it then. */
3341
+ scopeNamespace?: string,
3342
+ ): {
3343
+ key: string;
3344
+ label: string;
3345
+ matches: number;
3346
+ row?: InvestigationResourceSummary;
3347
+ }[] {
3348
+ const seen = new Set<string>();
3349
+ const out: {
3350
+ key: string;
3351
+ label: string;
3352
+ matches: number;
3353
+ row?: InvestigationResourceSummary;
3354
+ }[] = [];
3355
+ for (const item of items) {
3356
+ const subject = item.subject;
3357
+ if (!subject?.name) continue;
3358
+ // Agents write namespace "" for a cluster-scoped kind; that states none.
3359
+ const namespace = subject.namespace || undefined;
3360
+ const key = `${namespace ?? ""}/${subject.name}`;
3361
+ if (seen.has(key)) continue;
3362
+ seen.add(key);
3363
+ // A row without a namespace lives in the listing's scope, or in none
3364
+ // (a cluster-scoped kind); neither satisfies a namespace the citation
3365
+ // states unless it is the scope itself.
3366
+ const matches = resources.filter(
3367
+ (resource) =>
3368
+ resource.name === subject.name &&
3369
+ sameKind(resource.kind, subject.kind) &&
3370
+ (namespace === undefined ||
3371
+ (resource.namespace ?? scopeNamespace) === namespace),
3372
+ );
3373
+ out.push({
3374
+ key,
3375
+ label: namespace ? `${namespace}/${subject.name}` : subject.name,
3376
+ matches: matches.length,
3377
+ row: matches.length === 1 ? matches[0] : undefined,
3378
+ });
3379
+ }
3380
+ return out;
3381
+ }
3382
+
3383
+ function InventoryRow({
3384
+ resource,
3385
+ divider = false,
3386
+ }: {
3387
+ resource: InvestigationResourceSummary;
3388
+ divider?: boolean;
3389
+ }) {
3390
+ return (
3391
+ <div
3392
+ className={clsx(
3393
+ "flex min-w-0 items-center gap-2 px-2.5 py-1.5",
3394
+ divider && "border-t border-theme-border/60",
3395
+ )}
3396
+ >
3397
+ <StatusDot
3398
+ tone={mapHealthToTone(resource.summaryContext?.health ?? "")}
3399
+ className="shrink-0"
3400
+ />
3401
+ <Badge tone="structural" size="sm">
3402
+ {resource.kind}
3403
+ </Badge>
3404
+ <span className="min-w-0 flex-1">
3405
+ <span className="block truncate font-mono text-xs text-theme-text-secondary">
3406
+ {resource.namespace ? `${resource.namespace}/` : ""}
3407
+ {resource.name}
3408
+ </span>
3409
+ {resource.issue ? (
3410
+ <span className="block truncate text-xs text-warning-text">
3411
+ {resource.issue}
3412
+ </span>
3413
+ ) : null}
3414
+ </span>
3415
+ {resource.ready || resource.status ? (
3416
+ <span className="ml-auto shrink-0 font-mono text-xs text-theme-text-tertiary">
3417
+ {resource.ready || resource.status}
3418
+ </span>
3419
+ ) : null}
3420
+ {(resource.summaryContext?.issueCount ?? 0) > 0 ? (
3421
+ <Badge severity="warning" size="sm">
3422
+ {resource.summaryContext?.issueCount} issues
3423
+ </Badge>
3424
+ ) : null}
3425
+ </div>
3426
+ );
3427
+ }
3428
+
2757
3429
  function InventoryBody({ data }: { data: EvidenceDataOf<"inventory"> }) {
2758
3430
  return (
2759
3431
  <div className="max-h-72 overflow-y-auto rounded-md border border-theme-border">
2760
3432
  {data.resources.map((resource, index) => (
2761
- <div
3433
+ <InventoryRow
2762
3434
  key={`${resource.kind}-${resource.namespace ?? ""}-${resource.name}`}
2763
- className={clsx(
2764
- "flex min-w-0 items-center gap-2 px-2.5 py-1.5",
2765
- index > 0 && "border-t border-theme-border/60",
2766
- )}
2767
- >
2768
- <StatusDot
2769
- tone={mapHealthToTone(resource.summaryContext?.health ?? "")}
2770
- className="shrink-0"
2771
- />
2772
- <Badge tone="structural" size="sm">
2773
- {resource.kind}
2774
- </Badge>
2775
- <span className="min-w-0 flex-1">
2776
- <span className="block truncate font-mono text-xs text-theme-text-secondary">
2777
- {resource.namespace ? `${resource.namespace}/` : ""}
2778
- {resource.name}
2779
- </span>
2780
- {resource.issue ? (
2781
- <span className="block truncate text-xs text-warning-text">
2782
- {resource.issue}
2783
- </span>
2784
- ) : null}
2785
- </span>
2786
- {resource.ready || resource.status ? (
2787
- <span className="ml-auto shrink-0 font-mono text-xs text-theme-text-tertiary">
2788
- {resource.ready || resource.status}
2789
- </span>
2790
- ) : null}
2791
- {(resource.summaryContext?.issueCount ?? 0) > 0 ? (
2792
- <Badge severity="warning" size="sm">
2793
- {resource.summaryContext?.issueCount} issues
2794
- </Badge>
2795
- ) : null}
2796
- </div>
3435
+ resource={resource}
3436
+ divider={index > 0}
3437
+ />
2797
3438
  ))}
2798
3439
  </div>
2799
3440
  );
@@ -3338,7 +3979,7 @@ function RevisionHistory({
3338
3979
  )}
3339
3980
  </div>
3340
3981
  <SourceButton
3341
- ariaLabel={`View source for ${phaseLabel(observation.source.phase).toLowerCase()} observation of ${observation.title}`}
3982
+ ariaLabel={`View result for ${phaseLabel(observation.source.phase).toLowerCase()} observation of ${observation.title}`}
3342
3983
  onClick={() =>
3343
3984
  onViewSource(
3344
3985
  observation.source.id,
@@ -3412,7 +4053,7 @@ function EvidenceIcon({
3412
4053
 
3413
4054
  function SourceButton({
3414
4055
  ariaLabel,
3415
- buttonLabel = "View source",
4056
+ buttonLabel = "View result",
3416
4057
  compact = false,
3417
4058
  onClick,
3418
4059
  }: {