@skyhook-io/radar-app 1.13.0 → 1.13.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 (54) hide show
  1. package/package.json +2 -2
  2. package/src/App.tsx +55 -9
  3. package/src/RadarApp.tsx +3 -3
  4. package/src/api/diagnose.test.ts +268 -0
  5. package/src/api/diagnose.ts +117 -14
  6. package/src/components/diagnose/AISettings.tsx +10 -5
  7. package/src/components/diagnose/AgentSetupNotice.tsx +5 -5
  8. package/src/components/diagnose/ApplyDialog.test.tsx +72 -0
  9. package/src/components/diagnose/DiagnoseContext.tsx +228 -65
  10. package/src/components/diagnose/DiagnoseSurface.test.tsx +317 -9
  11. package/src/components/diagnose/DiagnoseSurface.tsx +621 -132
  12. package/src/components/diagnose/Home.test.tsx +293 -0
  13. package/src/components/diagnose/Home.tsx +307 -108
  14. package/src/components/diagnose/InvestigationEvidencePane.test.tsx +2170 -0
  15. package/src/components/diagnose/InvestigationEvidencePane.tsx +2253 -0
  16. package/src/components/diagnose/InvestigationResourceEvidence.test.tsx +257 -0
  17. package/src/components/diagnose/InvestigationResourceEvidence.tsx +214 -0
  18. package/src/components/diagnose/InvestigationView.test.ts +17 -0
  19. package/src/components/diagnose/InvestigationView.tsx +1911 -338
  20. package/src/components/diagnose/LocalDiagnoseAction.tsx +42 -25
  21. package/src/components/diagnose/agentCatalog.ts +1 -1
  22. package/src/components/diagnose/diagnoseEvidenceTypes.ts +151 -0
  23. package/src/components/diagnose/investigationEvidence.test.ts +3109 -0
  24. package/src/components/diagnose/investigationEvidence.ts +3492 -0
  25. package/src/components/diagnose/investigationEvidencePresentation.test.ts +447 -0
  26. package/src/components/diagnose/investigationEvidencePresentation.ts +167 -0
  27. package/src/components/diagnose/investigationExplanation.test.ts +63 -0
  28. package/src/components/diagnose/investigationExplanation.ts +22 -0
  29. package/src/components/diagnose/investigationResourceEvidenceModel.ts +322 -0
  30. package/src/components/diagnose/investigationSourceFocus.test.ts +143 -0
  31. package/src/components/diagnose/investigationSourceFocus.ts +98 -0
  32. package/src/components/diagnose/investigationState.test.ts +695 -0
  33. package/src/components/diagnose/investigationState.ts +451 -0
  34. package/src/components/diagnose/parts.test.tsx +864 -3
  35. package/src/components/diagnose/parts.tsx +1340 -536
  36. package/src/components/diagnose/target.test.ts +39 -0
  37. package/src/components/diagnose/target.ts +36 -0
  38. package/src/components/diagnose/useDisclosureReveal.ts +117 -0
  39. package/src/components/execution/BatchExecutionView.render.test.tsx +35 -0
  40. package/src/components/execution/BatchExecutionView.tsx +2 -3
  41. package/src/components/execution/execution-definition.test.ts +19 -0
  42. package/src/components/execution/execution-definition.ts +2 -0
  43. package/src/components/gitops/GitOpsView.tsx +25 -3
  44. package/src/components/home/MCPSetupDialog.tsx +2 -2
  45. package/src/components/home/mcpToolCatalog.test.ts +22 -0
  46. package/src/components/home/mcpToolCatalog.ts +3 -2
  47. package/src/components/issues/IssuesPane.tsx +5 -1
  48. package/src/components/nav/navigation.test.ts +26 -0
  49. package/src/components/nav/navigation.ts +10 -0
  50. package/src/components/settings/SettingsDialog.tsx +11 -13
  51. package/src/components/workload/WorkloadView.tsx +1 -1
  52. package/src/context/DiagnoseCustomization.tsx +11 -8
  53. package/src/index.css +63 -79
  54. package/src/index.ts +1 -1
@@ -2,7 +2,14 @@
2
2
  // persistence, no app/routing knowledge) so they lift cleanly into k8s-ui later
3
3
  // and Cloud can reuse them. The stateful controller lives in DiagnoseContext;
4
4
  // the run logic in InvestigationView.
5
- import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
5
+ import {
6
+ useEffect,
7
+ useId,
8
+ useMemo,
9
+ useRef,
10
+ useState,
11
+ type ReactNode,
12
+ } from "react";
6
13
  import {
7
14
  Loader2,
8
15
  CheckCircle2,
@@ -10,28 +17,43 @@ import {
10
17
  Copy,
11
18
  Check,
12
19
  ShieldCheck,
13
- ChevronRight,
14
20
  ChevronDown,
15
21
  Wrench,
16
22
  Sparkles,
17
23
  RefreshCw,
18
24
  Maximize2,
19
25
  HelpCircle,
26
+ FileSearch,
20
27
  } from "lucide-react";
21
28
  import { stringify as toYaml } from "yaml";
22
29
  import { codeToHtml } from "shiki";
23
30
  import { DialogPortal } from "@skyhook-io/k8s-ui/components/ui/DialogPortal";
31
+ import { parseContextName } from "../../utils/context-name";
24
32
  import { useTheme } from "../../context/ThemeContext";
25
33
  import { type DiagnoseConsentCopy } from "../../context/DiagnoseCustomization";
26
34
  import {
27
35
  type Diagnosis,
28
36
  type DiagnoseStep,
29
37
  type AgentInfo,
38
+ type ApplyMutationOutcome,
30
39
  type ExecutionProfile,
31
- type RunSummary,
32
40
  } from "../../api/diagnose";
33
- import { StatusDot } from "@skyhook-io/k8s-ui";
41
+ import { Collapse, CollapseChevron } from "@skyhook-io/k8s-ui";
34
42
  import { Markdown } from "../ui/Markdown";
43
+ import { Tooltip } from "../ui/Tooltip";
44
+ import type { InvestigationSourceExcerpt } from "./investigationSourceFocus";
45
+ import {
46
+ highlightRelatedEvidence,
47
+ locateSourceExcerpt,
48
+ } from "./investigationSourceFocus";
49
+ import {
50
+ investigationActivitySourceDomId,
51
+ investigationEvidenceSourceId,
52
+ type InvestigationRootCauseEvidenceResolution,
53
+ type InvestigationEvidenceSource,
54
+ } from "./investigationEvidence";
55
+
56
+ import { useDisclosureReveal } from "./useDisclosureReveal";
35
57
 
36
58
  const CURSOR_FULL_LOCAL_WARNING =
37
59
  "Radar passes Cursor --force, which auto-approves its built-in tools and every MCP server it loads, including your global servers. Cursor’s sandbox does not reliably confine those tools to Radar’s temporary workspace.";
@@ -232,7 +254,7 @@ function SelectMenu({
232
254
  );
233
255
  }
234
256
 
235
- // AgentControls is the full AI-diagnosis config block (agent, execution profile, model,
257
+ // AgentControls is the full AI investigation config block (agent, execution profile, model,
236
258
  // effort) — pure + prop-driven. It lives in Settings, not the investigation panel,
237
259
  // since these are set-once preferences rather than per-run knobs.
238
260
  export function AgentControls({
@@ -309,9 +331,9 @@ export function AgentControls({
309
331
  <AlertTriangle className="mt-0.5 h-3 w-3 shrink-0 text-amber-500" />
310
332
  <span>
311
333
  Uses your agent&apos;s normal configuration and other
312
- configured tools and MCP servers. Radar cannot constrain that
313
- external tooling; it may access local files or the network
314
- and may be able to change your cluster.{" "}
334
+ configured tools and MCP servers. Radar cannot constrain
335
+ that external tooling; it may access local files or the
336
+ network and may be able to change your cluster.{" "}
315
337
  {isCursor
316
338
  ? CURSOR_FULL_LOCAL_WARNING
317
339
  : isClaude
@@ -341,12 +363,13 @@ export function AgentControls({
341
363
  Radar must use this agent&apos;s normal setup. Radar cannot
342
364
  constrain its external tools or MCP servers; they may access
343
365
  local files or the network and may be able to change your
344
- cluster. {isCursor ? (
366
+ cluster.{" "}
367
+ {isCursor ? (
345
368
  CURSOR_FULL_LOCAL_WARNING
346
369
  ) : (
347
370
  <>
348
- Radar still enables the agent CLI&apos;s own sandbox, but that
349
- sandbox does not constrain external MCP servers.
371
+ Radar still enables the agent CLI&apos;s own sandbox, but
372
+ that sandbox does not constrain external MCP servers.
350
373
  </>
351
374
  )}
352
375
  </span>
@@ -404,7 +427,10 @@ export function AgentControls({
404
427
  // Turn is one round of the conversation: the initial investigation (no question)
405
428
  // or a follow-up, each with its own transcript + result.
406
429
  export type Turn = {
430
+ resultSequence?: number;
431
+ explainAssessment?: number;
407
432
  question?: string;
433
+ actor?: string;
408
434
  timeline: TimelineItem[];
409
435
  diagnosis: Diagnosis | null;
410
436
  error: string | null;
@@ -412,11 +438,21 @@ export type Turn = {
412
438
  // apply turns execute the recommended fix (write tools) — they report an
413
439
  // outcome, not a root cause, so the UI frames them differently.
414
440
  apply?: boolean;
441
+ // Set from the apply turn's terminal stream event. Green success is reserved
442
+ // for an explicit producer-confirmed mutation.
443
+ applyOutcome?: ApplyMutationOutcome;
444
+ // Verification turns are structurally a fresh health assessment, even though
445
+ // they carry a question. Keeping the bit explicit prevents the UI from
446
+ // misclassifying them as ordinary conversational follow-ups on replay.
447
+ verify?: boolean;
448
+ // Set from the replay/live boundary when the terminal event arrives. Historical
449
+ // conclusions render immediately; conclusions observed live enter smoothly.
450
+ animateResult?: boolean;
415
451
  };
416
452
 
417
453
  // TimelineItem is one ordered transcript entry: agent reasoning, or a tool call.
418
- type TimelineItem =
419
- | { kind: "thinking"; text: string }
454
+ export type TimelineItem =
455
+ | { kind: "thinking"; text: string; animate?: boolean }
420
456
  | {
421
457
  kind: "tool";
422
458
  id: string;
@@ -425,25 +461,66 @@ type TimelineItem =
425
461
  ms?: number;
426
462
  summary?: string;
427
463
  result?: string;
464
+ evidenceRef?: string;
465
+ radarEvidence?: boolean;
428
466
  truncated?: boolean;
467
+ // Tri-state by design: false = producer confirmed success, true = producer
468
+ // confirmed failure, undefined = this replay cannot establish the outcome.
469
+ isError?: boolean;
470
+ // Arrival motion is event-local so a replayed running turn can keep receiving
471
+ // live tool calls without reanimating the history reconstructed before it.
472
+ animate?: boolean;
429
473
  };
430
474
 
431
475
  export function appendThinking(
432
476
  prev: TimelineItem[],
433
477
  text: string,
478
+ animate = true,
434
479
  ): TimelineItem[] {
435
- const last = prev[prev.length - 1];
436
- if (last && last.kind === "thinking") {
437
- const next = [...prev];
438
- next[next.length - 1] = { ...last, text: (last.text + text).slice(-4000) };
439
- return next;
480
+ const normalized = text.replace(/\*\*\r?\n(?=\*\*)/g, "**\n\n");
481
+ // Agent CLIs commonly emit each short bold planning update as a complete
482
+ // stream event. Preserve those as discrete chronological beats instead of
483
+ // producing invalid/run-on Markdown such as `**one****two**`. Ordinary token
484
+ // chunks still concatenate into their current beat exactly as emitted.
485
+ const blocks = normalized.split(/\n{2,}(?=\s*\*\*)/);
486
+ const next = [...prev];
487
+ for (const block of blocks) {
488
+ if (!block) continue;
489
+ const last = next[next.length - 1];
490
+ const beginsBeat = /^\s*\*\*/.test(block);
491
+ const priorBeatComplete =
492
+ last?.kind === "thinking" && /\*\*\s*$/.test(last.text);
493
+ // Some agents repeat a bold phase heading at stream boundaries. Suppress
494
+ // only that presentation artifact; identical ordinary lines can be real
495
+ // evidence/reasoning and must survive both replay and live chunking.
496
+ if (
497
+ last?.kind === "thinking" &&
498
+ beginsBeat &&
499
+ priorBeatComplete &&
500
+ last.text.trim() === block.trim()
501
+ )
502
+ continue;
503
+ if (last?.kind === "thinking" && !(beginsBeat && priorBeatComplete)) {
504
+ next[next.length - 1] = {
505
+ ...last,
506
+ text: (last.text + block).slice(-4000),
507
+ animate: last.animate === true || animate,
508
+ };
509
+ continue;
510
+ }
511
+ next.push({
512
+ kind: "thinking",
513
+ text: block.trimStart(),
514
+ animate,
515
+ });
440
516
  }
441
- return [...prev, { kind: "thinking", text }];
517
+ return next;
442
518
  }
443
519
 
444
520
  export function upsertTool(
445
521
  prev: TimelineItem[],
446
522
  step: DiagnoseStep,
523
+ animate = true,
447
524
  ): TimelineItem[] {
448
525
  const i = prev.findIndex((it) => it.kind === "tool" && it.id === step.id);
449
526
  if (i >= 0) {
@@ -456,194 +533,171 @@ export function upsertTool(
456
533
  kind: "tool",
457
534
  tool: step.tool || cur.tool,
458
535
  summary: step.summary || cur.summary,
536
+ animate: cur.animate === true || animate,
459
537
  };
460
538
  return next;
461
539
  }
462
- return [...prev, { kind: "tool", ...step }];
540
+ return [...prev, { kind: "tool", ...step, animate }];
463
541
  }
464
542
 
465
543
  export function TurnView({
466
544
  turn,
467
- synthLabel,
468
- reveal = "full",
469
545
  onApply,
470
- onAsk,
546
+ onViewExplanation,
471
547
  onCheckStatus,
472
548
  onRetryDiagnosis,
473
- hideVerdict = false,
549
+ hideConclusion = false,
550
+ turnIndex,
551
+ evidenceStepIds,
552
+ onViewEvidence,
553
+ sourceRevealRequest,
474
554
  }: {
475
555
  turn: Turn;
476
- synthLabel?: string | null;
477
- reveal?: "rca" | "full";
478
556
  onApply?: (fix: string) => void;
479
- onAsk?: (question: string) => void;
557
+ onViewExplanation?: () => void;
480
558
  onCheckStatus?: () => void;
481
559
  onRetryDiagnosis?: () => void;
482
- // In the maximized workspace the pinned turn's verdict renders in the side rail,
560
+ // In the maximized workspace the pinned turn's conclusion renders in the side rail,
483
561
  // so the transcript suppresses its own copy (reasoning + tool calls still show).
484
- hideVerdict?: boolean;
562
+ hideConclusion?: boolean;
563
+ turnIndex?: number;
564
+ evidenceStepIds?: ReadonlySet<string>;
565
+ onViewEvidence?: (sourceId: string) => void;
566
+ sourceRevealRequest?: {
567
+ sourceId: string;
568
+ requestId: number;
569
+ excerpt?: InvestigationSourceExcerpt;
570
+ };
485
571
  }) {
486
572
  // A follow-up (a turn the user asked a question on) is a conversational reply,
487
573
  // not a fresh diagnosis — render it as a plain answer, never the root-cause
488
574
  // anchor or a remediation card.
489
- const followup = !!turn.question && !turn.apply;
575
+ const followup = !!turn.question && !turn.apply && !turn.verify;
490
576
  // Whether the done turn has anything for ResultCard to render — mirrors its
491
577
  // branch order exactly (apply → followup → structured/healthy), since a followup
492
578
  // ONLY ever renders FollowupAnswer (report/rootCause), never the remediation list.
493
579
  // When false, TurnView shows the narration or an explicit empty note, not a blank.
494
580
  const dx = turn.diagnosis;
495
- const hasVerdict = dx
496
- ? turn.apply
497
- ? true // ApplyOutcomeCard always renders an outcome
581
+ const hasResult = dx
582
+ ? followup
583
+ ? !!(dx.report?.trim() || dx.rootCause?.trim()) // FollowupAnswer
498
584
  : dx.healthy && !dx.rootCause
499
- ? true // AllClearCard (checked before followup in ResultCard)
585
+ ? true // AllClearCard
500
586
  : dx.inconclusive && !dx.rootCause
501
587
  ? true // InconclusiveCard
502
- : followup
503
- ? !!(dx.report?.trim() || dx.rootCause?.trim()) // FollowupAnswer
504
- : !!dx.rootCause ||
505
- (dx.remediation?.length ?? 0) > 0 ||
506
- !!dx.report?.trim()
588
+ : !!dx.rootCause ||
589
+ (dx.remediation?.length ?? 0) > 0 ||
590
+ !!dx.report?.trim()
507
591
  : false;
508
592
  return (
509
593
  <div className="space-y-2">
510
- {turn.question && (
511
- <div className="flex justify-end">
512
- <div className="max-w-[85%] rounded-lg rounded-br-sm bg-accent/10 px-3 py-1.5 text-sm text-theme-text-primary [overflow-wrap:anywhere]">
513
- {turn.question}
594
+ {turn.explainAssessment ? (
595
+ <button
596
+ type="button"
597
+ onClick={onViewExplanation}
598
+ className="flex items-center gap-1.5 rounded-md py-2 text-xs text-accent-text hover:underline"
599
+ >
600
+ <HelpCircle className="h-3.5 w-3.5" />
601
+ {turn.status === "running"
602
+ ? "Explaining assessment…"
603
+ : turn.status === "error"
604
+ ? "Explanation failed"
605
+ : "Plain-language explanation"}
606
+ <span className="text-theme-text-tertiary">· View in Findings</span>
607
+ </button>
608
+ ) : (
609
+ turn.question &&
610
+ (turn.verify ? (
611
+ <div className="flex items-center gap-2 rounded-md border border-theme-border/60 bg-theme-base/40 px-2.5 py-2 text-xs text-theme-text-secondary">
612
+ <RefreshCw className="h-3.5 w-3.5 shrink-0 text-accent" />
613
+ <span className="font-medium text-theme-text-primary">
614
+ Automatic verification
615
+ </span>
616
+ <span className="text-theme-text-tertiary">
617
+ Re-checking after apply
618
+ </span>
514
619
  </div>
515
- </div>
620
+ ) : (
621
+ <div className="flex justify-end">
622
+ <div className="max-w-[85%]">
623
+ {turn.actor && (
624
+ <div className="mb-0.5 text-right text-[10px] text-theme-text-tertiary">
625
+ {turn.actor}
626
+ </div>
627
+ )}
628
+ <div className="rounded-lg rounded-br-sm bg-accent/10 px-3 py-1.5 text-sm text-theme-text-primary [overflow-wrap:anywhere]">
629
+ {turn.question}
630
+ </div>
631
+ </div>
632
+ </div>
633
+ ))
516
634
  )}
517
635
  <Timeline
518
636
  items={turn.timeline}
519
637
  running={turn.status === "running"}
520
638
  applyMode={turn.apply}
521
639
  followup={followup}
522
- synthLabel={synthLabel}
640
+ turnIndex={turnIndex}
641
+ evidenceStepIds={evidenceStepIds}
642
+ onViewEvidence={onViewEvidence}
643
+ sourceRevealRequest={sourceRevealRequest}
523
644
  />
524
- {turn.status === "done" &&
525
- (hideVerdict && hasVerdict ? null : hasVerdict ? (
645
+ {!turn.explainAssessment &&
646
+ turn.status === "done" &&
647
+ (turn.apply ? (
648
+ <ApplyOutcomeCard
649
+ diagnosis={turn.diagnosis}
650
+ applyOutcome={turn.applyOutcome}
651
+ onCheckStatus={onCheckStatus}
652
+ animate={turn.animateResult !== false}
653
+ />
654
+ ) : hideConclusion && hasResult ? null : hasResult ? (
526
655
  <ResultCard
527
656
  diagnosis={turn.diagnosis!}
528
657
  onApply={onApply}
529
- onAsk={onAsk}
530
- apply={turn.apply}
531
658
  followup={followup}
532
- reveal={reveal}
533
659
  onCheckStatus={onCheckStatus}
660
+ animate={turn.animateResult !== false}
534
661
  />
535
662
  ) : (
536
- <EmptyResult />
663
+ <EmptyResult animate={turn.animateResult !== false} />
537
664
  ))}
538
- {turn.status === "error" && turn.error && (
665
+ {turn.explainAssessment ? null : turn.status === "error" && turn.apply ? (
666
+ <ApplyOutcomeCard
667
+ diagnosis={turn.diagnosis}
668
+ error={turn.error}
669
+ applyOutcome={turn.applyOutcome}
670
+ onCheckStatus={onCheckStatus}
671
+ animate={turn.animateResult !== false}
672
+ />
673
+ ) : turn.status === "error" && turn.error ? (
539
674
  <div className="flex items-start gap-2 rounded-lg border border-red-500/30 bg-red-500/10 p-3 text-sm text-theme-text-primary">
540
675
  <AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-red-400" />
541
676
  <div className="flex min-w-0 flex-col gap-2">
542
- <span className="whitespace-pre-wrap break-words">{turn.error}</span>
677
+ <span className="whitespace-pre-wrap break-words">
678
+ {turn.error}
679
+ </span>
543
680
  {onRetryDiagnosis && (
544
681
  <button
545
682
  type="button"
546
683
  onClick={onRetryDiagnosis}
547
684
  className="btn-brand self-start px-3 py-1 text-xs"
548
685
  >
549
- Retry diagnosis
686
+ Retry investigation
687
+ </button>
688
+ )}
689
+ {onCheckStatus && (
690
+ <button
691
+ type="button"
692
+ onClick={onCheckStatus}
693
+ className="btn-brand self-start px-3 py-1 text-xs"
694
+ >
695
+ Check current status
550
696
  </button>
551
697
  )}
552
698
  </div>
553
699
  </div>
554
- )}
555
- </div>
556
- );
557
- }
558
-
559
- // RunContextCard opens every investigation with what RADAR already knows — the
560
- // health frame the server captured at run start. It renders instantly (no agent
561
- // round-trip), so the agent's boot time reads as "context, then deepening"
562
- // instead of dead air — and it anchors the verdict against Radar's own signal.
563
- function healthLineTone(severity?: string): "unhealthy" | "degraded" | "alert" {
564
- if (severity === "critical") return "unhealthy";
565
- if (severity === "warning") return "degraded";
566
- return "alert";
567
- }
568
-
569
- export function RunContextCard({ run }: { run: RunSummary }) {
570
- const h = run.health;
571
- const issueCount = h?.issueCount ?? 0;
572
- const issues = h?.issues ?? [];
573
- const findings = h?.auditFindings ?? [];
574
- const lines: ReactNode[] = [];
575
- if (issues.length > 0) {
576
- // The actual issue rows Radar's engine flagged — the reason bolded, the
577
- // engine's own detail sentence after it. Concrete beats a count.
578
- for (const [i, line] of issues.entries()) {
579
- lines.push(
580
- <div key={`issue-${i}`} className="flex items-start gap-1.5">
581
- <StatusDot
582
- tone={healthLineTone(line.severity)}
583
- className="mt-1 shrink-0"
584
- />
585
- <span className="min-w-0">
586
- <span className="font-medium text-theme-text-primary">
587
- {line.reason}
588
- </span>
589
- {line.message ? <> — {line.message}</> : null}
590
- </span>
591
- </div>,
592
- );
593
- }
594
- if (issueCount > issues.length) {
595
- lines.push(
596
- <div key="more" className="pl-3.5 text-theme-text-tertiary">
597
- +{issueCount - issues.length} more active issue
598
- {issueCount - issues.length === 1 ? "" : "s"}
599
- </div>,
600
- );
601
- }
602
- } else if (h?.health === "healthy") {
603
- lines.push(
604
- <div key="healthy" className="flex items-center gap-1.5">
605
- <StatusDot tone="healthy" className="shrink-0" />
606
- Reported healthy — 0 active issues
607
- </div>,
608
- );
609
- } else if (h) {
610
- lines.push(
611
- <div key="none" className="flex items-center gap-1.5">
612
- <StatusDot tone="unknown" className="shrink-0" />0 active issues
613
- {h.health ? ` — status ${h.health}` : ""}
614
- </div>,
615
- );
616
- }
617
- for (const [i, f] of findings.entries()) {
618
- lines.push(
619
- <div key={`audit-${i}`} className="pl-3.5 text-theme-text-tertiary">
620
- Audit: <span className="font-medium">{f.reason}</span>
621
- {f.message ? <> — {f.message}</> : null}
622
- </div>,
623
- );
624
- }
625
- if ((h?.auditCount ?? 0) > findings.length && findings.length > 0) {
626
- lines.push(
627
- <div key="audit-more" className="pl-3.5 text-theme-text-tertiary">
628
- +{h!.auditCount! - findings.length} more audit finding
629
- {h!.auditCount! - findings.length === 1 ? "" : "s"}
630
- </div>,
631
- );
632
- }
633
- if (run.managedBy) {
634
- lines.push(
635
- <div key="managed" className="pl-3.5 text-theme-text-tertiary">
636
- Managed by {run.managedBy}
637
- </div>,
638
- );
639
- }
640
- if (lines.length === 0) return null;
641
- return (
642
- <div className="rounded-md border border-theme-border/60 bg-theme-base/40 px-2.5 py-2">
643
- <div className="mb-1 text-[10px] font-semibold uppercase tracking-wide text-theme-text-tertiary">
644
- Radar&apos;s read at start
645
- </div>
646
- <div className="space-y-1 text-xs text-theme-text-secondary">{lines}</div>
700
+ ) : null}
647
701
  </div>
648
702
  );
649
703
  }
@@ -697,7 +751,9 @@ function ConsentCardShell({
697
751
  {title}
698
752
  </div>
699
753
  </div>
700
- <div className="text-sm leading-relaxed text-theme-text-secondary">{body}</div>
754
+ <div className="text-sm leading-relaxed text-theme-text-secondary">
755
+ {body}
756
+ </div>
701
757
  {bullets && bullets.length > 0 && (
702
758
  <ul className="mt-2 space-y-1 text-xs text-theme-text-tertiary">
703
759
  {bullets.map((b, i) => (
@@ -816,16 +872,16 @@ export function ConsentCard({
816
872
  ? [
817
873
  agent === "claude" ? (
818
874
  <>
819
- Radar safeguards disable Claude&apos;s built-in tools and limit
820
- MCP access to Radar&apos;s read-only investigation tools. Your
821
- Claude settings, hooks, and CLAUDE.md instructions still apply
822
- and are outside Radar&apos;s control.
875
+ Radar safeguards disable Claude&apos;s built-in tools and
876
+ limit MCP access to Radar&apos;s read-only investigation
877
+ tools. Your Claude settings, hooks, and CLAUDE.md instructions
878
+ still apply and are outside Radar&apos;s control.
823
879
  </>
824
880
  ) : agent === "codex" ? (
825
881
  <>
826
- Radar safeguards exclude your Codex configuration and other MCP
827
- servers. Codex&apos;s sandboxed shell can still read files on
828
- this machine; it cannot write or reach the network.
882
+ Radar safeguards exclude your Codex configuration and other
883
+ MCP servers. Codex&apos;s sandboxed shell can still read files
884
+ on this machine; it cannot write or reach the network.
829
885
  </>
830
886
  ) : (
831
887
  <>
@@ -837,9 +893,9 @@ export function ConsentCard({
837
893
  ]
838
894
  : [
839
895
  <>
840
- Radar cannot constrain the agent&apos;s other configured tools or
841
- MCP servers. They may access local files or the network and may
842
- be able to change your cluster.
896
+ Radar cannot constrain the agent&apos;s other configured tools
897
+ or MCP servers. They may access local files or the network and
898
+ may be able to change your cluster.
843
899
  </>,
844
900
  agent === "cursor-agent" ? (
845
901
  CURSOR_FULL_LOCAL_WARNING
@@ -868,6 +924,7 @@ export function ApplyDialog({
868
924
  onConfirm,
869
925
  agentLabel,
870
926
  resourceLabel,
927
+ context,
871
928
  fix,
872
929
  managedBy,
873
930
  confidence,
@@ -877,6 +934,7 @@ export function ApplyDialog({
877
934
  onConfirm: () => void;
878
935
  agentLabel: string;
879
936
  resourceLabel: string;
937
+ context: string;
880
938
  fix?: string;
881
939
  managedBy?: string; // GitOps/Helm owner of the resource, if any
882
940
  confidence?: number;
@@ -895,8 +953,12 @@ export function ApplyDialog({
895
953
  }, [open]);
896
954
  const applyBlocked = !!managedBy && !acked;
897
955
  return (
898
- <DialogPortal open={open} onClose={onClose} className="max-w-lg w-full">
899
- <div className="flex items-start gap-3 border-b border-theme-border p-4">
956
+ <DialogPortal
957
+ open={open}
958
+ onClose={onClose}
959
+ className="flex max-h-[calc(100dvh-2rem)] w-full max-w-2xl flex-col overflow-hidden"
960
+ >
961
+ <div className="flex shrink-0 items-start gap-3 border-b border-theme-border p-4">
900
962
  <div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-amber-500/20">
901
963
  <AlertTriangle className="h-5 w-5 text-amber-500" />
902
964
  </div>
@@ -911,22 +973,34 @@ export function ApplyDialog({
911
973
  </span>
912
974
  .
913
975
  </p>
976
+ <p className="mt-2 text-sm text-theme-text-secondary">
977
+ Cluster:{" "}
978
+ <span className="font-medium text-theme-text-primary">
979
+ {parseContextName(context).clusterName}
980
+ </span>
981
+ </p>
982
+ {context !== parseContextName(context).clusterName && (
983
+ <p className="mt-0.5 break-all font-mono text-xs text-theme-text-tertiary">
984
+ {context}
985
+ </p>
986
+ )}
914
987
  </div>
915
988
  </div>
916
989
 
917
- {fixText && (
918
- <div className="border-b border-theme-border p-4">
919
- <div className="mb-1.5 flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-accent">
920
- <Sparkles className="h-3.5 w-3.5" />
921
- What will happen
990
+ <div className="min-h-0 overflow-y-auto">
991
+ {fixText && (
992
+ <div className="border-b border-theme-border p-4">
993
+ <div className="mb-1.5 flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-accent">
994
+ <Sparkles className="h-3.5 w-3.5" />
995
+ Proposed change
996
+ </div>
997
+ <AIMarkdown className="text-sm text-theme-text-primary [overflow-wrap:anywhere] [&_code]:font-normal [&_p]:my-0 [&_p]:text-theme-text-primary [&_pre]:my-1.5 [&_pre]:whitespace-pre-wrap [&_pre_code]:whitespace-pre-wrap">
998
+ {fixText}
999
+ </AIMarkdown>
922
1000
  </div>
923
- <AIMarkdown className="max-h-48 overflow-auto text-sm text-theme-text-primary [overflow-wrap:anywhere] [&_code]:font-normal [&_p]:my-0 [&_p]:text-theme-text-primary [&_pre]:my-1.5">
924
- {fixText}
925
- </AIMarkdown>
926
- </div>
927
- )}
928
-
929
- <div className="space-y-2 p-4">
1001
+ )}
1002
+ </div>
1003
+ <div className="shrink-0 space-y-2 p-4">
930
1004
  {/* The star warning: when we KNOW a controller owns this resource, a live
931
1005
  change reverts on the next reconcile — say so authoritatively. */}
932
1006
  {managedBy && (
@@ -956,7 +1030,7 @@ export function ApplyDialog({
956
1030
  <AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-theme-text-tertiary" />
957
1031
  <span>
958
1032
  The agent had <span className="font-medium">low confidence</span>{" "}
959
- in this diagnosis — consider asking a follow-up to verify before
1033
+ in this conclusion — consider asking a follow-up to verify before
960
1034
  applying.
961
1035
  </span>
962
1036
  </div>
@@ -970,8 +1044,7 @@ export function ApplyDialog({
970
1044
  </span>
971
1045
  </div>
972
1046
  </div>
973
-
974
- <div className="flex items-center justify-end gap-3 border-t border-theme-border p-4">
1047
+ <div className="flex shrink-0 items-center justify-end gap-3 border-t border-theme-border p-4">
975
1048
  <button
976
1049
  onClick={onClose}
977
1050
  className="rounded-lg px-4 py-2 text-sm font-medium text-theme-text-secondary transition-colors hover:bg-theme-elevated hover:text-theme-text-primary"
@@ -996,13 +1069,23 @@ export function Timeline({
996
1069
  running,
997
1070
  applyMode,
998
1071
  followup,
999
- synthLabel,
1072
+ turnIndex,
1073
+ evidenceStepIds,
1074
+ onViewEvidence,
1075
+ sourceRevealRequest,
1000
1076
  }: {
1001
1077
  items: TimelineItem[];
1002
1078
  running: boolean;
1003
1079
  applyMode?: boolean;
1004
1080
  followup?: boolean;
1005
- synthLabel?: string | null;
1081
+ turnIndex?: number;
1082
+ evidenceStepIds?: ReadonlySet<string>;
1083
+ onViewEvidence?: (sourceId: string) => void;
1084
+ sourceRevealRequest?: {
1085
+ sourceId: string;
1086
+ requestId: number;
1087
+ excerpt?: InvestigationSourceExcerpt;
1088
+ };
1006
1089
  }) {
1007
1090
  const heading = applyMode
1008
1091
  ? "Applying fix"
@@ -1014,8 +1097,7 @@ export function Timeline({
1014
1097
  const activeTool = [...items]
1015
1098
  .reverse()
1016
1099
  .find((it) => it.kind === "tool" && it.status !== "done") as
1017
- | Extract<TimelineItem, { kind: "tool" }>
1018
- | undefined;
1100
+ Extract<TimelineItem, { kind: "tool" }> | undefined;
1019
1101
  const runningLabel = applyMode
1020
1102
  ? "Applying the fix…"
1021
1103
  : activeTool
@@ -1032,26 +1114,115 @@ export function Timeline({
1032
1114
  {heading}
1033
1115
  </div>
1034
1116
  )}
1035
- {items.map((it, i) =>
1036
- it.kind === "thinking" ? (
1037
- // The model's reasoning between tool calls — muted + subordinate to the
1038
- // tool rows. Rendered as markdown so Codex's summary headers read cleanly.
1039
- <AIMarkdown
1040
- key={i}
1041
- className="animate-transcript-enter py-0.5 text-xs leading-relaxed text-theme-text-tertiary [overflow-wrap:anywhere] [&_li]:text-theme-text-tertiary [&_p]:my-0.5 [&_strong]:font-medium [&_strong]:text-theme-text-secondary"
1042
- >
1043
- {it.text}
1117
+ {items.map((it, i) => {
1118
+ if (it.kind === "thinking") {
1119
+ return (
1120
+ <ThinkingBlock
1121
+ key={i}
1122
+ text={it.text}
1123
+ animate={it.animate !== false}
1124
+ live={running && i === items.length - 1}
1125
+ />
1126
+ );
1127
+ }
1128
+ const sourceId =
1129
+ turnIndex === undefined
1130
+ ? undefined
1131
+ : investigationEvidenceSourceId(turnIndex, it.id);
1132
+ return (
1133
+ <ToolRow
1134
+ key={it.id}
1135
+ step={it}
1136
+ sourceId={sourceId}
1137
+ hasEvidence={evidenceStepIds?.has(it.id) ?? false}
1138
+ onViewEvidence={onViewEvidence}
1139
+ revealRequestId={
1140
+ sourceRevealRequest && sourceId === sourceRevealRequest.sourceId
1141
+ ? sourceRevealRequest.requestId
1142
+ : undefined
1143
+ }
1144
+ sourceExcerpt={
1145
+ sourceRevealRequest && sourceRevealRequest.sourceId === sourceId
1146
+ ? sourceRevealRequest.excerpt
1147
+ : undefined
1148
+ }
1149
+ animate={it.animate !== false}
1150
+ />
1151
+ );
1152
+ })}
1153
+ {running && <RunningStatus label={runningLabel} />}
1154
+ </div>
1155
+ );
1156
+ }
1157
+
1158
+ // The model's reasoning between tool calls — muted + subordinate to the tool
1159
+ // rows, and clamped once its beat is over so the chronology stays scannable.
1160
+ // The final beat remains fully visible while it is streaming; completed prose
1161
+ // is still available through an overflow-aware disclosure.
1162
+ function ThinkingBlock({
1163
+ text,
1164
+ animate,
1165
+ live,
1166
+ }: {
1167
+ text: string;
1168
+ animate: boolean;
1169
+ live: boolean;
1170
+ }) {
1171
+ const [expanded, setExpanded] = useState(false);
1172
+ const [contentHeight, setContentHeight] = useState<number>();
1173
+ const contentRef = useRef<HTMLDivElement>(null);
1174
+ const contentId = useId();
1175
+ const reveal = useDisclosureReveal<HTMLDivElement>();
1176
+ // Two lines of 12px/19.5px prose plus 8px paragraph/container spacing. Unlike a disclosure
1177
+ // this starts with a visible preview, so animate measured height using the
1178
+ // same 200ms ease-out/reduced-motion treatment as Collapse.
1179
+ const previewHeight = 47;
1180
+ const clamped = !live && !expanded;
1181
+ useEffect(() => {
1182
+ const content = contentRef.current;
1183
+ if (!content) return;
1184
+ const measure = () => setContentHeight(content.scrollHeight);
1185
+ measure();
1186
+ if (typeof ResizeObserver === "undefined") return;
1187
+ const observer = new ResizeObserver(measure);
1188
+ observer.observe(content);
1189
+ return () => observer.disconnect();
1190
+ }, [text]);
1191
+
1192
+ return (
1193
+ <div
1194
+ ref={reveal.elementRef}
1195
+ className={animate ? "animate-transcript-enter" : ""}
1196
+ >
1197
+ <div
1198
+ id={contentId}
1199
+ className="overflow-hidden transition-[height] duration-200 ease-out motion-reduce:transition-none"
1200
+ style={{
1201
+ height: clamped
1202
+ ? Math.min(contentHeight ?? previewHeight, previewHeight)
1203
+ : contentHeight,
1204
+ }}
1205
+ >
1206
+ <div ref={contentRef}>
1207
+ <AIMarkdown className="py-0.5 text-xs leading-relaxed text-theme-text-tertiary [overflow-wrap:anywhere] [&_li]:text-theme-text-tertiary [&_p]:my-0.5 [&_strong]:font-medium [&_strong]:text-theme-text-secondary">
1208
+ {text}
1044
1209
  </AIMarkdown>
1045
- ) : (
1046
- <ToolRow key={it.id} step={it} />
1047
- ),
1048
- )}
1049
- {running &&
1050
- (synthLabel ? (
1051
- <SynthBeat label={synthLabel} />
1052
- ) : (
1053
- <RunningStatus label={runningLabel} />
1054
- ))}
1210
+ </div>
1211
+ </div>
1212
+ {!live && ((contentHeight ?? 0) > previewHeight || expanded) ? (
1213
+ <button
1214
+ type="button"
1215
+ aria-controls={contentId}
1216
+ aria-expanded={expanded}
1217
+ onClick={() => {
1218
+ setExpanded(!expanded);
1219
+ reveal.revealAfterToggle(!expanded);
1220
+ }}
1221
+ className="text-[11px] font-medium text-theme-text-tertiary hover:text-accent-text"
1222
+ >
1223
+ {expanded ? "Show less" : "Show reasoning"}
1224
+ </button>
1225
+ ) : null}
1055
1226
  </div>
1056
1227
  );
1057
1228
  }
@@ -1099,17 +1270,6 @@ function RunningStatus({ label }: { label: string }) {
1099
1270
  );
1100
1271
  }
1101
1272
 
1102
- // A staged "thinking" beat — a calm breathing dot + shimmering label. Used both in
1103
- // the timeline (pre-verdict) and between the root-cause and remediation cards.
1104
- function SynthBeat({ label }: { label: string }) {
1105
- return (
1106
- <div className="flex items-center gap-2 pt-1 text-xs animate-transcript-enter">
1107
- <span className="h-1.5 w-1.5 shrink-0 rounded-full bg-accent animate-synth-pulse" />
1108
- <span className="ai-shimmer font-medium">{label}…</span>
1109
- </div>
1110
- );
1111
- }
1112
-
1113
1273
  // Maps a running tool to a human verb so the status line reads as activity, not
1114
1274
  // machinery. Falls back to the prettified tool name for anything unmapped.
1115
1275
  function toolActivity(tool: string): string {
@@ -1126,69 +1286,194 @@ function toolActivity(tool: string): string {
1126
1286
  return `${prettyTool(tool)}…`;
1127
1287
  }
1128
1288
 
1129
- function ToolRow({ step }: { step: Extract<TimelineItem, { kind: "tool" }> }) {
1130
- const [open, setOpen] = useState(false);
1289
+ function ToolRow({
1290
+ step,
1291
+ sourceId,
1292
+ hasEvidence,
1293
+ onViewEvidence,
1294
+ revealRequestId,
1295
+ sourceExcerpt,
1296
+ animate,
1297
+ }: {
1298
+ step: Extract<TimelineItem, { kind: "tool" }>;
1299
+ sourceId?: string;
1300
+ hasEvidence?: boolean;
1301
+ onViewEvidence?: (sourceId: string) => void;
1302
+ revealRequestId?: number;
1303
+ sourceExcerpt?: InvestigationSourceExcerpt;
1304
+ animate: boolean;
1305
+ }) {
1306
+ const [open, setOpen] = useState(revealRequestId !== undefined);
1131
1307
  const [showFull, setShowFull] = useState(false);
1308
+ const [argumentsOpen, setArgumentsOpen] = useState(false);
1309
+ const toolReveal = useDisclosureReveal<HTMLDivElement>();
1310
+ const argumentsReveal = useDisclosureReveal<HTMLDivElement>();
1311
+ const argumentsId = useId();
1312
+ const argumentsPreview = step.summary ? compactArgs(step.summary) : "";
1313
+ const inlineArguments =
1314
+ argumentsPreview.length <= 240 && !argumentsPreview.includes("\n");
1315
+ const detailId = `investigation-tool-detail-${useId().replaceAll(":", "")}`;
1132
1316
  const hasDetail = !!(step.summary || step.result);
1317
+ useEffect(() => {
1318
+ if (revealRequestId !== undefined && hasDetail) setOpen(true);
1319
+ }, [hasDetail, revealRequestId]);
1133
1320
  // Offer the rich dialog when the result is structured or non-trivial in size.
1134
1321
  const richResult =
1135
1322
  !!step.result && (isJsonPayload(step.result) || step.result.length > 200);
1323
+ const done = step.status === "done";
1324
+ const outcomeLabel = !done
1325
+ ? "Running"
1326
+ : step.isError === true
1327
+ ? "Tool failed"
1328
+ : step.isError === false
1329
+ ? "Tool completed"
1330
+ : "Tool finished; outcome not recorded";
1331
+ const rowContent = (
1332
+ <>
1333
+ {!done ? (
1334
+ <Loader2
1335
+ aria-label={outcomeLabel}
1336
+ className="h-3.5 w-3.5 shrink-0 animate-spin text-accent"
1337
+ />
1338
+ ) : step.isError === true ? (
1339
+ <AlertTriangle
1340
+ aria-label={outcomeLabel}
1341
+ className="h-3.5 w-3.5 shrink-0 text-red-400"
1342
+ />
1343
+ ) : step.isError === false ? (
1344
+ <CheckCircle2
1345
+ aria-label={outcomeLabel}
1346
+ className="h-3.5 w-3.5 shrink-0 text-emerald-400"
1347
+ />
1348
+ ) : (
1349
+ <HelpCircle
1350
+ aria-label={outcomeLabel}
1351
+ className="h-3.5 w-3.5 shrink-0 text-theme-text-tertiary"
1352
+ />
1353
+ )}
1354
+ <span className="shrink-0 font-mono text-xs text-theme-text-secondary">
1355
+ {prettyTool(step.tool)}
1356
+ </span>
1357
+ {step.summary && !open && (
1358
+ <span className="min-w-0 flex-1 truncate font-mono text-[11px] text-theme-text-tertiary">
1359
+ {argumentsPreview}
1360
+ </span>
1361
+ )}
1362
+ {step.ms != null && (
1363
+ <span className="ml-auto shrink-0 text-[11px] text-theme-text-tertiary">
1364
+ {step.ms}ms
1365
+ </span>
1366
+ )}
1367
+ {hasDetail && <CollapseChevron open={open} className="h-3.5 w-3.5" />}
1368
+ </>
1369
+ );
1136
1370
  return (
1137
- <div className="animate-transcript-enter rounded-md border border-theme-border/60 bg-theme-base/40">
1138
- <button
1139
- onClick={() => hasDetail && setOpen((v) => !v)}
1140
- className={`flex w-full items-center gap-2 px-2 py-1.5 text-left text-sm ${
1141
- hasDetail ? "hover:bg-theme-hover" : "cursor-default"
1142
- }`}
1143
- >
1144
- {step.status === "done" ? (
1145
- <CheckCircle2 className="h-3.5 w-3.5 shrink-0 text-emerald-400" />
1371
+ <div
1372
+ onMouseEnter={(event) =>
1373
+ highlightRelatedEvidence(event.currentTarget, sourceId)
1374
+ }
1375
+ onMouseLeave={(event) => highlightRelatedEvidence(event.currentTarget)}
1376
+ ref={toolReveal.elementRef}
1377
+ id={sourceId ? investigationActivitySourceDomId(sourceId) : undefined}
1378
+ tabIndex={sourceId ? -1 : undefined}
1379
+ role={sourceId ? "group" : undefined}
1380
+ aria-label={
1381
+ sourceId
1382
+ ? `${prettyTool(step.tool)} ${outcomeLabel.toLowerCase()}`
1383
+ : undefined
1384
+ }
1385
+ className={`${animate ? "animate-transcript-enter" : ""} scroll-mt-3 rounded-md border border-theme-border/60 bg-theme-base/40 outline-none focus:ring-2 focus:ring-accent/50`}
1386
+ >
1387
+ <div className="flex min-w-0 items-stretch">
1388
+ {hasDetail ? (
1389
+ <button
1390
+ type="button"
1391
+ onClick={() => {
1392
+ setOpen(!open);
1393
+ toolReveal.revealAfterToggle(!open);
1394
+ }}
1395
+ aria-expanded={open}
1396
+ aria-controls={detailId}
1397
+ className="flex min-w-0 flex-1 items-center gap-2 px-2 py-1.5 text-left text-sm hover:bg-theme-hover"
1398
+ >
1399
+ {rowContent}
1400
+ </button>
1146
1401
  ) : (
1147
- <Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin text-accent" />
1148
- )}
1149
- <span className="font-mono text-xs text-theme-text-secondary">
1150
- {prettyTool(step.tool)}
1151
- </span>
1152
- {step.summary && (
1153
- <span className="min-w-0 flex-1 truncate font-mono text-[11px] text-theme-text-tertiary">
1154
- {compactArgs(step.summary)}
1155
- </span>
1156
- )}
1157
- {step.ms != null && (
1158
- <span className="ml-auto shrink-0 text-[11px] text-theme-text-tertiary">
1159
- {step.ms}ms
1160
- </span>
1161
- )}
1162
- {hasDetail && (
1163
- <ChevronRight
1164
- className={`h-3.5 w-3.5 shrink-0 text-theme-text-tertiary transition-transform ${open ? "rotate-90" : ""}`}
1165
- />
1402
+ <div className="flex min-w-0 flex-1 items-center gap-2 px-2 py-1.5 text-left text-sm">
1403
+ {rowContent}
1404
+ </div>
1166
1405
  )}
1167
- </button>
1406
+ {hasEvidence && sourceId && onViewEvidence ? (
1407
+ <button
1408
+ type="button"
1409
+ onClick={() => onViewEvidence(sourceId)}
1410
+ aria-label={`View evidence from ${prettyTool(step.tool)}`}
1411
+ className="investigation-evidence-jump shrink-0 px-2 text-[11px] font-medium text-accent-text hover:bg-theme-hover"
1412
+ >
1413
+ Show evidence
1414
+ </button>
1415
+ ) : null}
1416
+ </div>
1168
1417
  {hasDetail && (
1169
- <Collapse open={open}>
1170
- <div className="space-y-2 border-t border-theme-border/60 px-2 py-2">
1171
- {step.summary && <PayloadBlock label="Input" text={step.summary} />}
1172
- {step.result && (
1173
- <PayloadBlock
1174
- label="Result"
1175
- text={step.result}
1176
- truncated={step.truncated}
1177
- action={
1178
- richResult ? (
1418
+ <div id={detailId}>
1419
+ <Collapse open={open}>
1420
+ <div className="space-y-2 border-t border-theme-border/60 px-2 py-2">
1421
+ {step.summary &&
1422
+ (inlineArguments ? (
1423
+ <div
1424
+ className="break-words font-mono text-[11px] leading-relaxed text-theme-text-secondary [overflow-wrap:anywhere]"
1425
+ aria-label="Tool arguments"
1426
+ >
1427
+ {argumentsPreview}
1428
+ </div>
1429
+ ) : (
1430
+ <div ref={argumentsReveal.elementRef}>
1179
1431
  <button
1180
- onClick={() => setShowFull(true)}
1181
- className="flex items-center gap-1 text-[11px] text-accent hover:underline"
1432
+ type="button"
1433
+ aria-expanded={argumentsOpen}
1434
+ aria-controls={argumentsId}
1435
+ onClick={() => {
1436
+ setArgumentsOpen(!argumentsOpen);
1437
+ argumentsReveal.revealAfterToggle(!argumentsOpen);
1438
+ }}
1439
+ className="flex items-center gap-1 py-1 text-xs text-theme-text-tertiary hover:text-theme-text-primary"
1182
1440
  >
1183
- <Maximize2 className="h-3 w-3" />
1184
- View payload
1441
+ <CollapseChevron
1442
+ open={argumentsOpen}
1443
+ className="h-3.5 w-3.5"
1444
+ />
1445
+ {argumentsOpen ? "Hide arguments" : "Show arguments"}
1185
1446
  </button>
1186
- ) : undefined
1187
- }
1188
- />
1189
- )}
1190
- </div>
1191
- </Collapse>
1447
+ <div id={argumentsId}>
1448
+ <Collapse open={argumentsOpen} mountLazily>
1449
+ <PayloadBlock label="Arguments" text={step.summary} />
1450
+ </Collapse>
1451
+ </div>
1452
+ </div>
1453
+ ))}
1454
+ {step.result && (
1455
+ <PayloadBlock
1456
+ label="Original result"
1457
+ text={step.result}
1458
+ sourceExcerpt={sourceExcerpt}
1459
+ revealRequestId={revealRequestId}
1460
+ truncated={step.truncated}
1461
+ action={
1462
+ richResult ? (
1463
+ <button
1464
+ onClick={() => setShowFull(true)}
1465
+ className="flex items-center gap-1 text-[11px] text-accent hover:underline"
1466
+ >
1467
+ <Maximize2 className="h-3 w-3" />
1468
+ View payload
1469
+ </button>
1470
+ ) : undefined
1471
+ }
1472
+ />
1473
+ )}
1474
+ </div>
1475
+ </Collapse>
1476
+ </div>
1192
1477
  )}
1193
1478
  {step.result && (
1194
1479
  <ToolResultDialog
@@ -1227,13 +1512,36 @@ function PayloadBlock({
1227
1512
  text,
1228
1513
  truncated,
1229
1514
  action,
1515
+ sourceExcerpt,
1516
+ revealRequestId,
1230
1517
  }: {
1231
1518
  label: string;
1232
1519
  text: string;
1233
1520
  truncated?: boolean;
1234
1521
  action?: ReactNode;
1522
+ sourceExcerpt?: InvestigationSourceExcerpt;
1523
+ revealRequestId?: number;
1235
1524
  }) {
1236
1525
  const json = formatJson(text);
1526
+ const display = json ?? text;
1527
+ const range = locateSourceExcerpt(display, sourceExcerpt);
1528
+ const rangeStart = range?.start;
1529
+ const rangeEnd = range?.end;
1530
+ const preRef = useRef<HTMLPreElement>(null);
1531
+ const markRef = useRef<HTMLElement>(null);
1532
+ useEffect(() => {
1533
+ if (rangeStart === undefined || revealRequestId === undefined) return;
1534
+ const frame = requestAnimationFrame(() => {
1535
+ const pre = preRef.current,
1536
+ mark = markRef.current;
1537
+ if (pre && mark)
1538
+ pre.scrollTop +=
1539
+ mark.getBoundingClientRect().top -
1540
+ pre.getBoundingClientRect().top -
1541
+ pre.clientHeight / 3;
1542
+ });
1543
+ return () => cancelAnimationFrame(frame);
1544
+ }, [revealRequestId, rangeStart, rangeEnd]);
1237
1545
  return (
1238
1546
  <div>
1239
1547
  <div className="mb-0.5 flex items-center justify-between gap-2">
@@ -1242,13 +1550,30 @@ function PayloadBlock({
1242
1550
  </span>
1243
1551
  <div className="flex items-center gap-2">
1244
1552
  {action}
1245
- <CopyButton text={json ?? text} />
1553
+ <CopyButton
1554
+ text={json ?? text}
1555
+ label={`Copy ${label.toLowerCase()}`}
1556
+ />
1246
1557
  </div>
1247
1558
  </div>
1248
1559
  <pre
1249
- className={`max-h-64 overflow-auto rounded bg-theme-elevated p-1.5 font-mono text-[11px] text-theme-text-secondary ${json ? "" : "whitespace-pre-wrap [overflow-wrap:anywhere]"}`}
1560
+ ref={preRef}
1561
+ className={`max-h-64 overflow-auto rounded bg-theme-elevated p-1.5 font-mono text-[11px] text-theme-text-secondary ${json && !range ? "" : "whitespace-pre-wrap [overflow-wrap:anywhere]"}`}
1250
1562
  >
1251
- {json ?? text}
1563
+ {range ? (
1564
+ <>
1565
+ {display.slice(0, range.start)}
1566
+ <mark
1567
+ ref={markRef}
1568
+ className="bg-accent-muted text-theme-text-primary ring-1 ring-accent/40"
1569
+ >
1570
+ {display.slice(range.start, range.end)}
1571
+ </mark>
1572
+ {display.slice(range.end)}
1573
+ </>
1574
+ ) : (
1575
+ display
1576
+ )}
1252
1577
  </pre>
1253
1578
  {truncated && (
1254
1579
  <div className="mt-0.5 text-[10px] text-amber-500">
@@ -1337,7 +1662,7 @@ function ToolResultDialog({
1337
1662
  />
1338
1663
  </div>
1339
1664
  )}
1340
- <CopyButton text={display} />
1665
+ <CopyButton text={display} label="Copy tool result" />
1341
1666
  </div>
1342
1667
  </div>
1343
1668
  {html ? (
@@ -1362,18 +1687,6 @@ function safeYaml(value: unknown): string {
1362
1687
  }
1363
1688
  }
1364
1689
 
1365
- // Collapse — the Radar-standard expand/collapse motion (grid-template-rows
1366
- // 0fr↔1fr) used across issue rows. Children stay mounted so close animates too.
1367
- function Collapse({ open, children }: { open: boolean; children: ReactNode }) {
1368
- return (
1369
- <div
1370
- className={`issue-details-motion ${open ? "issue-details-motion-open" : ""}`}
1371
- >
1372
- <div className="overflow-hidden">{children}</div>
1373
- </div>
1374
- );
1375
- }
1376
-
1377
1690
  function compactArgs(raw: string): string {
1378
1691
  try {
1379
1692
  const o = JSON.parse(raw);
@@ -1388,71 +1701,271 @@ function compactArgs(raw: string): string {
1388
1701
  export function ResultCard({
1389
1702
  diagnosis,
1390
1703
  onApply,
1391
- onAsk,
1704
+ explanation,
1392
1705
  apply,
1706
+ applyOutcome,
1393
1707
  followup,
1394
- reveal = "full",
1708
+ section = "full",
1395
1709
  onCheckStatus,
1710
+ animate = true,
1711
+ showDisclaimer = true,
1712
+ coverageLimited = false,
1713
+ evidenceConflict = false,
1714
+ compactActions = false,
1715
+ assessmentAction,
1716
+ assessmentSources,
1717
+ actionNotice,
1396
1718
  }: {
1397
1719
  diagnosis: Diagnosis;
1398
1720
  onApply?: (fix: string) => void;
1399
- onAsk?: (question: string) => void;
1721
+ explanation?: AssessmentExplanation;
1400
1722
  apply?: boolean;
1723
+ applyOutcome?: ApplyMutationOutcome;
1401
1724
  followup?: boolean;
1402
- reveal?: "rca" | "full";
1725
+ section?: "full" | "conclusion" | "actions";
1403
1726
  onCheckStatus?: () => void;
1727
+ animate?: boolean;
1728
+ /** Additional navigation placed in the assessment action row. */
1729
+ assessmentAction?: ReactNode;
1730
+ assessmentSources?: ReactNode;
1731
+ /** Hide the repeated disclaimer when the enclosing surface provides context. */
1732
+ showDisclaimer?: boolean;
1733
+ /** Qualifies a healthy assessment when structured evidence is absent or partial. */
1734
+ coverageLimited?: boolean;
1735
+ /** Marks a healthy agent assessment that conflicts with same-turn Key evidence. */
1736
+ evidenceConflict?: boolean;
1737
+ /** Show only the recommended (or first) action until the user asks for more. */
1738
+ compactActions?: boolean;
1739
+ actionNotice?: string;
1404
1740
  }) {
1405
- // Apply turns report what changed — an outcome, not a diagnosis. Frame as a
1406
- // success confirmation (emerald) rather than the amber root-cause anchor.
1741
+ // Apply turns report mutation truth, not a diagnosis. The outcome-specific
1742
+ // card decides whether that truth is confirmed, failed, or still unknown.
1407
1743
  if (apply)
1408
1744
  return (
1409
- <ApplyOutcomeCard diagnosis={diagnosis} onCheckStatus={onCheckStatus} />
1745
+ <ApplyOutcomeCard
1746
+ diagnosis={diagnosis}
1747
+ applyOutcome={applyOutcome}
1748
+ onCheckStatus={onCheckStatus}
1749
+ animate={animate}
1750
+ />
1410
1751
  );
1752
+ // A question remains conversational even if the model happens to set a health
1753
+ // flag in its structured envelope. Never promote an ordinary answer into an
1754
+ // authoritative investigation conclusion.
1755
+ if (followup)
1756
+ return <FollowupAnswer diagnosis={diagnosis} animate={animate} />;
1411
1757
  if (diagnosis.healthy && !diagnosis.rootCause)
1412
- return <AllClearCard diagnosis={diagnosis} />;
1758
+ return section === "actions" ? null : (
1759
+ <AllClearCard
1760
+ diagnosis={diagnosis}
1761
+ animate={animate}
1762
+ showDisclaimer={showDisclaimer}
1763
+ coverageLimited={coverageLimited}
1764
+ evidenceConflict={evidenceConflict}
1765
+ assessmentAction={assessmentAction}
1766
+ assessmentSources={assessmentSources}
1767
+ />
1768
+ );
1413
1769
  // Couldn't-determine is its own honest state — never a confident all-clear, never
1414
1770
  // the alarming root-cause anchor.
1415
1771
  if (diagnosis.inconclusive && !diagnosis.rootCause)
1416
- return <InconclusiveCard diagnosis={diagnosis} />;
1417
- // Follow-ups are conversational replies, not fresh diagnoses — plain answer.
1418
- if (followup) return <FollowupAnswer diagnosis={diagnosis} />;
1419
-
1772
+ return section === "actions" ? null : (
1773
+ <>
1774
+ <InconclusiveCard diagnosis={diagnosis} animate={animate} />
1775
+ {assessmentSources ? (
1776
+ <AssessmentSourceDetails>{assessmentSources}</AssessmentSourceDetails>
1777
+ ) : null}
1778
+ {assessmentAction && (
1779
+ <div className="mt-2 flex justify-end">{assessmentAction}</div>
1780
+ )}
1781
+ </>
1782
+ );
1420
1783
  // A turn with no structured root cause and no remediation (e.g. "looks healthy",
1421
1784
  // or a clarifying question) is not a diagnosis — render it neutrally rather than
1422
1785
  // forcing the alarming root-cause anchor onto a non-problem.
1423
1786
  const structured =
1424
1787
  !!diagnosis.rootCause || (diagnosis.remediation?.length ?? 0) > 0;
1425
- if (!structured) return <FollowupAnswer diagnosis={diagnosis} />;
1788
+ if (!structured)
1789
+ return (
1790
+ <>
1791
+ <FollowupAnswer diagnosis={diagnosis} animate={animate} />
1792
+ {assessmentSources ? (
1793
+ <AssessmentSourceDetails>{assessmentSources}</AssessmentSourceDetails>
1794
+ ) : null}
1795
+ {assessmentAction && (
1796
+ <div className="mt-2 flex justify-end">{assessmentAction}</div>
1797
+ )}
1798
+ </>
1799
+ );
1426
1800
 
1427
1801
  return (
1428
1802
  <DiagnosisResult
1429
1803
  diagnosis={diagnosis}
1430
1804
  onApply={onApply}
1431
- onAsk={onAsk}
1432
- reveal={reveal}
1805
+ explanation={explanation}
1806
+ section={section}
1807
+ animate={animate}
1808
+ showDisclaimer={showDisclaimer}
1809
+ compactActions={compactActions}
1810
+ assessmentAction={assessmentAction}
1811
+ assessmentSources={assessmentSources}
1812
+ actionNotice={actionNotice}
1433
1813
  />
1434
1814
  );
1435
1815
  }
1436
1816
 
1437
- const EXPLAIN_SIMPLY_PROMPT =
1438
- "Explain this in plain language for someone who isn't a Kubernetes expert — what's broken, why it matters, and what each remediation step actually does. Gloss any k8s terms.";
1817
+ export function AssessmentSources({
1818
+ resolution,
1819
+ onViewSource,
1820
+ }: {
1821
+ resolution?: InvestigationRootCauseEvidenceResolution;
1822
+ onViewSource: (sourceId: string) => void;
1823
+ }) {
1824
+ if (resolution?.status !== "linked" || !resolution.links.length) return null;
1825
+ return (
1826
+ <div className="mt-3 border-t border-theme-border/60 pt-2">
1827
+ <h4 className="text-xs font-medium text-theme-text-secondary">
1828
+ Sources used for this assessment
1829
+ </h4>
1830
+ <ul className="mt-1 space-y-1">
1831
+ {resolution.links.map((link) => (
1832
+ <li key={link.source.id}>
1833
+ <button
1834
+ type="button"
1835
+ aria-label={`View ${prettyTool(link.source.tool)} source used for this assessment`}
1836
+ onClick={() => onViewSource(link.source.id)}
1837
+ className="flex w-full min-w-0 items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs text-accent-text hover:bg-theme-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/50"
1838
+ >
1839
+ <FileSearch className="h-3.5 w-3.5 shrink-0" aria-hidden />
1840
+ <span className="min-w-0 flex-1">
1841
+ <span className="font-medium">
1842
+ {prettyTool(link.source.tool)}
1843
+ </span>
1844
+ <CitedSourceScope source={link.source} />
1845
+ </span>
1846
+ <span className="shrink-0">View source</span>
1847
+ </button>
1848
+ </li>
1849
+ ))}
1850
+ </ul>
1851
+ </div>
1852
+ );
1853
+ }
1854
+
1855
+ function AssessmentSourceDetails({ children }: { children: ReactNode }) {
1856
+ const [open, setOpen] = useState(false);
1857
+ const id = useId();
1858
+ const reveal = useDisclosureReveal<HTMLDivElement>();
1859
+ return (
1860
+ <div ref={reveal.elementRef}>
1861
+ <button
1862
+ type="button"
1863
+ aria-expanded={open}
1864
+ aria-controls={id}
1865
+ onClick={() => {
1866
+ setOpen(!open);
1867
+ reveal.revealAfterToggle(!open);
1868
+ }}
1869
+ className="flex items-center gap-1.5 rounded-md py-2 text-xs font-medium text-theme-text-secondary hover:text-theme-text-primary"
1870
+ >
1871
+ <CollapseChevron open={open} className="h-3.5 w-3.5" />
1872
+ Assessment details
1873
+ </button>
1874
+ <div id={id}>
1875
+ <Collapse open={open}>{children}</Collapse>
1876
+ </div>
1877
+ </div>
1878
+ );
1879
+ }
1880
+
1881
+ function CitedSourceScope({ source }: { source: InvestigationEvidenceSource }) {
1882
+ let input: unknown;
1883
+ try {
1884
+ input = JSON.parse(source.args ?? "");
1885
+ } catch {
1886
+ return null;
1887
+ }
1888
+ if (!input || typeof input !== "object" || Array.isArray(input)) return null;
1889
+ const args = input as Record<string, unknown>;
1890
+ const fields = ["query", "kind", "namespace", "name", "filter"].flatMap(
1891
+ (key) =>
1892
+ typeof args[key] === "string" && args[key].trim()
1893
+ ? [`${key === "query" ? "" : `${key}: `}${args[key]}`]
1894
+ : [],
1895
+ );
1896
+ return fields.length > 0 ? (
1897
+ <span className="mt-0.5 block break-words text-theme-text-secondary [overflow-wrap:anywhere]">
1898
+ {fields.join(" · ")}
1899
+ </span>
1900
+ ) : null;
1901
+ }
1902
+
1903
+ export type AssessmentExplanation = {
1904
+ status: "idle" | "running" | "done" | "error";
1905
+ text?: string;
1906
+ error?: string;
1907
+ onGenerate?: () => void;
1908
+ openRequest?: number;
1909
+ };
1439
1910
 
1440
- // The diagnosis result: root cause + remediation (any step applyable) + the
1911
+ // The diagnosis result: likely cause + remediation + the
1441
1912
  // agent's full analysis on demand.
1442
1913
  function DiagnosisResult({
1443
1914
  diagnosis,
1444
1915
  onApply,
1445
- onAsk,
1446
- reveal = "full",
1916
+ explanation,
1917
+ section = "full",
1918
+ animate,
1919
+ showDisclaimer,
1920
+ compactActions,
1921
+ assessmentAction,
1922
+ assessmentSources,
1923
+ actionNotice,
1447
1924
  }: {
1448
1925
  diagnosis: Diagnosis;
1449
1926
  onApply?: (fix: string) => void;
1450
- onAsk?: (question: string) => void;
1451
- reveal?: "rca" | "full";
1927
+ explanation?: AssessmentExplanation;
1928
+ section?: "full" | "conclusion" | "actions";
1929
+ animate: boolean;
1930
+ showDisclaimer: boolean;
1931
+ compactActions: boolean;
1932
+ assessmentAction?: ReactNode;
1933
+ assessmentSources?: ReactNode;
1934
+ actionNotice?: string;
1452
1935
  }) {
1453
- const [showAnalysis, setShowAnalysis] = useState(false);
1454
- // Only a real structured root cause anchors the amber card; the full prose lives
1455
- // in "Full analysis" (never relabel the report as a root cause).
1936
+ const [detail, setDetail] = useState<"analysis" | "explanation" | null>(
1937
+ explanation?.status === "running" ? "explanation" : null,
1938
+ );
1939
+ const showAnalysis = detail === "analysis";
1940
+ const analysisReveal = useDisclosureReveal<HTMLDivElement>();
1941
+ const { elementRef: analysisElementRef, revealAfterToggle: revealAnalysis } =
1942
+ analysisReveal;
1943
+ useEffect(() => {
1944
+ if (explanation?.openRequest) {
1945
+ setDetail("explanation");
1946
+ revealAnalysis(true);
1947
+ }
1948
+ }, [explanation?.openRequest, revealAnalysis]);
1949
+ useEffect(() => {
1950
+ if (
1951
+ detail === "explanation" &&
1952
+ (explanation?.status === "done" || explanation?.status === "error")
1953
+ ) {
1954
+ const element = analysisElementRef.current;
1955
+ const scroller = element?.closest("[data-investigation-findings-scroll]");
1956
+ if (element && scroller) {
1957
+ const top = element.getBoundingClientRect().top;
1958
+ const viewport = scroller.getBoundingClientRect();
1959
+ if (top >= viewport.top && top < viewport.bottom) revealAnalysis(true);
1960
+ }
1961
+ }
1962
+ }, [explanation?.status, detail, analysisElementRef, revealAnalysis]);
1963
+ const [showAllSteps, setShowAllSteps] = useState(false);
1964
+ const stepsId = useId();
1965
+ const stepsReveal = useDisclosureReveal<HTMLDivElement>();
1966
+ const analysisId = useId();
1967
+ // Only a real structured cause anchors the amber card; the full prose lives in
1968
+ // "Full analysis" (never relabel the report as a causal assessment).
1456
1969
  const rootCause = diagnosis.rootCause;
1457
1970
  const remediation = diagnosis.remediation || [];
1458
1971
  const hasRemediation = remediation.length > 0;
@@ -1463,161 +1976,321 @@ function DiagnosisResult({
1463
1976
  // When it returns 0 / none ("needs human judgement"), we honor that and don't
1464
1977
  // offer one-click apply — the steps stay copy-only with a note.
1465
1978
  const canApply = !!onApply && recValid;
1979
+ const showConclusion = section !== "actions";
1980
+ const showActions = section !== "conclusion";
1981
+ const remediationEntries = remediation.map((text, index) => ({
1982
+ text,
1983
+ index,
1984
+ }));
1985
+ const primaryActionIndex = recValid ? recIdx! - 1 : 0;
1986
+ const hiddenStepCount = remediationEntries.length - 1;
1987
+ const renderRemediationStep = ({
1988
+ text: r,
1989
+ index: i,
1990
+ }: {
1991
+ text: string;
1992
+ index: number;
1993
+ }) => {
1994
+ const isRec = recValid && i === recIdx! - 1;
1995
+ return (
1996
+ <div
1997
+ key={i}
1998
+ className={
1999
+ isRec ? "rounded-lg border border-accent/40 bg-accent/5 p-2.5" : ""
2000
+ }
2001
+ >
2002
+ <div className="flex items-start gap-2">
2003
+ <span
2004
+ className={`mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-full text-[10px] ${
2005
+ isRec
2006
+ ? "bg-accent/20 text-accent"
2007
+ : "bg-theme-base text-theme-text-tertiary"
2008
+ }`}
2009
+ >
2010
+ {i + 1}
2011
+ </span>
2012
+ <div className="min-w-0 flex-1">
2013
+ {isRec && (
2014
+ <div className="mb-1">
2015
+ <div className="flex items-center gap-1 text-[10px] font-semibold uppercase tracking-wide text-accent">
2016
+ <Sparkles className="h-3 w-3" />
2017
+ Recommended
2018
+ </div>
2019
+ {diagnosis.recommendedReason && (
2020
+ <div className="mt-0.5 max-w-[100ch] text-[11px] leading-snug text-theme-text-tertiary">
2021
+ {diagnosis.recommendedReason}
2022
+ </div>
2023
+ )}
2024
+ </div>
2025
+ )}
2026
+ <AIMarkdown className="max-w-[100ch] text-sm [overflow-wrap:anywhere] [&_p]:my-0 [&_pre]:my-1.5">
2027
+ {r}
2028
+ </AIMarkdown>
2029
+ </div>
2030
+ {/* Action cluster: compact Apply (recommended = subtly
2031
+ filled, others = ghost) sits next to Copy so each row's
2032
+ actions stay together. The ellipsis signals a confirm
2033
+ dialog follows — it doesn't apply immediately. */}
2034
+ <div className="flex shrink-0 items-center gap-0.5">
2035
+ {canApply && isRec && (
2036
+ <button
2037
+ onClick={() => onApply!(r)}
2038
+ className={`inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium text-accent transition-colors ${
2039
+ isRec
2040
+ ? "border border-accent/40 bg-accent/10 hover:bg-accent/20"
2041
+ : "hover:bg-accent/10"
2042
+ }`}
2043
+ >
2044
+ <Wrench className="h-3 w-3" />
2045
+ Apply…
2046
+ </button>
2047
+ )}
2048
+ <CopyButton text={r} label={`Copy remediation step ${i + 1}`} />
2049
+ </div>
2050
+ </div>
2051
+ </div>
2052
+ );
2053
+ };
1466
2054
  return (
1467
- <div className="mt-3 space-y-2 animate-result-in">
1468
- {/* Root cause — the anchor: distinct tone + heavier type so it pops. */}
1469
- {rootCause && (
2055
+ <div className={`mt-3 space-y-2 ${animate ? "animate-result-in" : ""}`}>
2056
+ {/* Likely cause — agent-authored, visually prominent without claiming proof. */}
2057
+ {showConclusion && rootCause && (
1470
2058
  <div
1471
- className="rounded-lg border border-amber-500/30 bg-amber-500/5 p-3 animate-verdict-reveal"
1472
- style={{ "--glow": "rgb(245 158 11)" } as React.CSSProperties}
2059
+ className={
2060
+ section === "conclusion"
2061
+ ? "grid grid-cols-[minmax(0,1fr)_auto] items-start gap-2"
2062
+ : "rounded-lg border border-theme-border bg-theme-surface p-3"
2063
+ }
1473
2064
  >
1474
- <div className="mb-1 flex items-center justify-between gap-2">
1475
- <div className="relative flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-amber-500">
1476
- <AlertTriangle className="h-3.5 w-3.5" />
1477
- Root cause
1478
- <span className="absolute -bottom-0.5 left-0 right-0 h-px bg-amber-500/60 animate-underline-sweep" />
1479
- </div>
2065
+ <div
2066
+ className={
2067
+ section === "conclusion"
2068
+ ? "col-start-2 row-start-1"
2069
+ : "mb-1 flex items-center justify-between gap-2"
2070
+ }
2071
+ >
2072
+ {section !== "conclusion" && (
2073
+ <div className="text-xs font-medium text-theme-text-secondary">
2074
+ Likely cause
2075
+ </div>
2076
+ )}
1480
2077
  <div className="flex items-center gap-2">
1481
- {diagnosis.confidence != null ? (
1482
- <ConfidenceMeter value={diagnosis.confidence} />
1483
- ) : (
1484
- <ConfidenceUnstated />
1485
- )}
1486
- <CopyButton text={rootCause} />
2078
+ <CopyButton text={rootCause} label="Copy likely cause" />
1487
2079
  </div>
1488
2080
  </div>
1489
- <AIMarkdown className="text-sm font-medium text-theme-text-primary [overflow-wrap:anywhere] [&_code]:font-normal [&_p]:my-0 [&_p]:text-theme-text-primary">
2081
+ <AIMarkdown
2082
+ className={`${section === "conclusion" ? "col-start-1 row-start-1 max-w-[100ch]" : "max-w-prose"} text-sm leading-relaxed text-theme-text-primary [overflow-wrap:anywhere] [&_code]:font-normal [&_p]:my-0 [&_p]:text-theme-text-primary`}
2083
+ >
1490
2084
  {rootCause}
1491
2085
  </AIMarkdown>
1492
- {onAsk && reveal === "full" && (
1493
- <button
1494
- onClick={() => onAsk(EXPLAIN_SIMPLY_PROMPT)}
1495
- className="mt-2 inline-flex items-center gap-1 rounded-md border border-theme-border px-2 py-1 text-[11px] font-medium text-theme-text-secondary hover:bg-theme-hover hover:text-theme-text-primary"
1496
- >
1497
- <HelpCircle className="h-3 w-3" />
1498
- Explain simply
1499
- </button>
1500
- )}
1501
2086
  </div>
1502
2087
  )}
1503
2088
 
1504
- {/* Between the root cause and the remediation, a beat lands where the steps
1505
- will appear — the verdict unfolds rather than dumping all at once. */}
1506
- {reveal === "rca" && hasRemediation && (
1507
- <SynthBeat label="Weighing remediation options" />
1508
- )}
1509
-
1510
- {/* Remediation — copyable steps; the recommended one is highlighted as the
1511
- default, and any step can be applied (Apply binds to that step's text). */}
1512
- {reveal === "full" && hasRemediation && (
2089
+ {/* Remediation — every step is copyable. Only the explicitly recommended
2090
+ step can be applied, and only when the caller enables apply. */}
2091
+ {showActions && hasRemediation && (
1513
2092
  <div
1514
- className="rounded-lg border border-theme-border bg-theme-elevated p-3 animate-verdict-reveal"
1515
- style={{ "--glow": "var(--accent)" } as React.CSSProperties}
2093
+ ref={stepsReveal.elementRef}
2094
+ className={
2095
+ section === "actions"
2096
+ ? "space-y-2"
2097
+ : "rounded-lg border border-theme-border bg-theme-surface p-3"
2098
+ }
1516
2099
  >
1517
- <div className="mb-2 flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-theme-text-tertiary">
1518
- <Wrench className="h-3.5 w-3.5 text-accent" />
1519
- Remediation
1520
- </div>
1521
- <ol className="space-y-2">
1522
- {remediation.map((r, i) => {
1523
- const isRec = recValid && i === recIdx! - 1;
1524
- return (
1525
- <li
1526
- key={i}
1527
- className={`animate-transcript-enter ${
1528
- isRec
1529
- ? "rounded-lg border border-accent/40 bg-accent/5 p-2.5"
1530
- : ""
1531
- }`}
1532
- style={{ animationDelay: `${260 + i * 70}ms` }}
1533
- >
1534
- <div className="flex items-start gap-2">
1535
- <span
1536
- className={`mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-full text-[10px] ${
1537
- isRec
1538
- ? "bg-accent/20 text-accent"
1539
- : "bg-theme-base text-theme-text-tertiary"
1540
- }`}
1541
- >
1542
- {i + 1}
1543
- </span>
1544
- <div className="min-w-0 flex-1">
1545
- {isRec && (
1546
- <div className="mb-1">
1547
- <div className="flex items-center gap-1 text-[10px] font-semibold uppercase tracking-wide text-accent">
1548
- <Sparkles className="h-3 w-3" />
1549
- Recommended
1550
- </div>
1551
- {diagnosis.recommendedReason && (
1552
- <div className="mt-0.5 text-[11px] leading-snug text-theme-text-tertiary">
1553
- {diagnosis.recommendedReason}
1554
- </div>
1555
- )}
1556
- </div>
1557
- )}
1558
- <AIMarkdown className="text-sm [overflow-wrap:anywhere] [&_p]:my-0 [&_pre]:my-1.5">
1559
- {r}
1560
- </AIMarkdown>
1561
- </div>
1562
- {/* Action cluster: compact Apply (recommended = subtly
1563
- filled, others = ghost) sits next to Copy so each row's
1564
- actions stay together. The ellipsis signals a confirm
1565
- dialog follows — it doesn't apply immediately. */}
1566
- <div className="flex shrink-0 items-center gap-0.5">
1567
- {canApply && (
1568
- <button
1569
- onClick={() => onApply!(r)}
1570
- className={`inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium text-accent transition-colors ${
1571
- isRec
1572
- ? "border border-accent/40 bg-accent/10 hover:bg-accent/20"
1573
- : "hover:bg-accent/10"
1574
- }`}
1575
- >
1576
- <Wrench className="h-3 w-3" />
1577
- Apply…
1578
- </button>
1579
- )}
1580
- <CopyButton text={r} />
1581
- </div>
1582
- </div>
2100
+ {section !== "actions" && (
2101
+ <div className="mb-2 flex items-center gap-1.5 text-xs font-medium text-theme-text-secondary">
2102
+ <Wrench className="h-3.5 w-3.5 text-accent" />
2103
+ Remediation
2104
+ </div>
2105
+ )}
2106
+ {actionNotice && (
2107
+ <p className="text-xs text-theme-text-secondary">{actionNotice}</p>
2108
+ )}
2109
+ <div id={stepsId}>
2110
+ <ol>
2111
+ {remediationEntries.map((entry) => (
2112
+ <li key={entry.index} value={entry.index + 1}>
2113
+ <Collapse
2114
+ open={
2115
+ !compactActions ||
2116
+ showAllSteps ||
2117
+ entry.index === primaryActionIndex
2118
+ }
2119
+ mountLazily
2120
+ >
2121
+ <div className="pt-2">{renderRemediationStep(entry)}</div>
2122
+ </Collapse>
1583
2123
  </li>
1584
- );
1585
- })}
1586
- </ol>
1587
- {!recValid && (
2124
+ ))}
2125
+ </ol>
2126
+ </div>
2127
+ {compactActions && remediation.length > 1 ? (
2128
+ <button
2129
+ type="button"
2130
+ aria-expanded={showAllSteps}
2131
+ aria-controls={stepsId}
2132
+ onClick={() => {
2133
+ setShowAllSteps(!showAllSteps);
2134
+ stepsReveal.revealAfterToggle(!showAllSteps);
2135
+ }}
2136
+ className="mt-2 inline-flex items-center gap-1 rounded-md px-1.5 py-1 text-[11px] font-medium text-theme-text-secondary hover:bg-theme-hover hover:text-theme-text-primary"
2137
+ >
2138
+ <CollapseChevron open={showAllSteps} className="h-3.5 w-3.5" />
2139
+ {showAllSteps
2140
+ ? recValid
2141
+ ? "Show only recommended step"
2142
+ : "Show only first step"
2143
+ : `Show ${hiddenStepCount} more ${hiddenStepCount === 1 ? "step" : "steps"}`}
2144
+ </button>
2145
+ ) : null}
2146
+ {!actionNotice && !recValid && (
1588
2147
  <p className="mt-2 flex items-start gap-1.5 text-[11px] leading-snug text-theme-text-tertiary">
1589
2148
  <ShieldCheck className="mt-0.5 h-3 w-3 shrink-0" />
1590
- No safe one-click fix — the agent flagged this as needing your
1591
- judgement. Review the steps, or resume in your agent to apply them
1592
- interactively.
2149
+ No one-click fix is available. Review these steps and apply them
2150
+ manually, or ask the agent to continue.
1593
2151
  </p>
1594
2152
  )}
1595
2153
  </div>
1596
2154
  )}
1597
2155
 
1598
2156
  {/* Full analysis — the agent's detailed evidence, on demand. */}
1599
- {reveal === "full" && diagnosis.report && (
1600
- <div className="rounded-lg border border-theme-border bg-theme-elevated">
1601
- <button
1602
- onClick={() => setShowAnalysis((v) => !v)}
1603
- className="flex w-full items-center gap-1.5 px-3 py-2 text-xs font-medium uppercase tracking-wide text-theme-text-tertiary hover:text-theme-text-primary"
1604
- >
1605
- <ChevronRight
1606
- className={`h-3.5 w-3.5 transition-transform ${showAnalysis ? "rotate-90" : ""}`}
1607
- />
1608
- Full analysis
1609
- </button>
1610
- <Collapse open={showAnalysis}>
1611
- <div className="border-t border-theme-border/60 px-3 py-2">
1612
- <AIMarkdown className="text-sm [overflow-wrap:anywhere] [&_h2:first-child]:mt-0 [&_h2]:mb-1.5 [&_h2]:mt-3 [&_h2]:text-xs [&_h2]:font-semibold [&_h2]:uppercase [&_h2]:tracking-wide [&_h2]:text-theme-text-tertiary [&_h3]:text-sm [&_li]:text-theme-text-secondary [&_p]:my-1.5 [&_p]:text-theme-text-secondary">
1613
- {diagnosis.report}
1614
- </AIMarkdown>
2157
+ {showConclusion &&
2158
+ (diagnosis.report ||
2159
+ diagnosis.confidence != null ||
2160
+ explanation ||
2161
+ assessmentAction ||
2162
+ assessmentSources) && (
2163
+ <div>
2164
+ <div
2165
+ className="flex flex-wrap items-center gap-x-3 gap-y-1 pt-2"
2166
+ data-assessment-actions
2167
+ >
2168
+ {(diagnosis.report ||
2169
+ diagnosis.confidence != null ||
2170
+ assessmentSources) && (
2171
+ <button
2172
+ type="button"
2173
+ aria-expanded={showAnalysis}
2174
+ aria-controls={`${analysisId}-analysis`}
2175
+ onClick={() => {
2176
+ setDetail(showAnalysis ? null : "analysis");
2177
+ analysisReveal.revealAfterToggle(!showAnalysis);
2178
+ }}
2179
+ className="flex items-center gap-1.5 rounded-md py-2 text-xs font-medium text-theme-text-secondary hover:text-theme-text-primary"
2180
+ >
2181
+ <CollapseChevron
2182
+ open={showAnalysis}
2183
+ className="h-3.5 w-3.5"
2184
+ />
2185
+ {diagnosis.report ? "Full analysis" : "Assessment details"}
2186
+ </button>
2187
+ )}
2188
+ {explanation && (
2189
+ <Tooltip
2190
+ content={
2191
+ explanation.status === "idle"
2192
+ ? explanation.onGenerate
2193
+ ? "Ask the agent to explain this assessment and its proposed next steps in plain language."
2194
+ : "Wait for the current agent request to finish before requesting an explanation."
2195
+ : explanation.status === "running"
2196
+ ? "The agent is preparing an explanation. You can close this and return while it runs."
2197
+ : explanation.status === "error"
2198
+ ? "View the explanation error and retry when the agent is available."
2199
+ : "Show the saved plain-language explanation. No new request is needed."
2200
+ }
2201
+ >
2202
+ <button
2203
+ type="button"
2204
+ aria-expanded={detail === "explanation"}
2205
+ aria-controls={`${analysisId}-explanation`}
2206
+ disabled={
2207
+ explanation.status === "idle" && !explanation.onGenerate
2208
+ }
2209
+ onClick={() => {
2210
+ const open = detail !== "explanation";
2211
+ setDetail(open ? "explanation" : null);
2212
+ analysisReveal.revealAfterToggle(open);
2213
+ if (open && explanation.status === "idle")
2214
+ explanation.onGenerate?.();
2215
+ }}
2216
+ className="inline-flex items-center gap-1 rounded-md px-2 py-1.5 text-xs font-medium text-theme-text-secondary hover:bg-theme-hover hover:text-theme-text-primary disabled:opacity-50"
2217
+ >
2218
+ <HelpCircle className="h-3 w-3" />
2219
+ Explain simply
2220
+ </button>
2221
+ </Tooltip>
2222
+ )}
2223
+ {assessmentAction && (
2224
+ <div className="ml-auto">{assessmentAction}</div>
2225
+ )}
1615
2226
  </div>
1616
- </Collapse>
1617
- </div>
1618
- )}
2227
+ <div id={analysisId} ref={analysisReveal.elementRef}>
2228
+ <div id={`${analysisId}-analysis`}>
2229
+ <Collapse open={detail === "analysis"} mountLazily>
2230
+ <div className="border-t border-theme-border/60 px-3 py-2">
2231
+ <p className="mb-2 text-xs text-theme-text-tertiary">
2232
+ Agent confidence:{" "}
2233
+ {diagnosis.confidence != null
2234
+ ? confidenceLabel(diagnosis.confidence)
2235
+ : "not stated"}
2236
+ {diagnosis.confidence != null ? " · self-reported" : ""}
2237
+ </p>
2238
+ <AIMarkdown className="text-sm [overflow-wrap:anywhere] [&_h2:first-child]:mt-0 [&_h2]:mb-1.5 [&_h2]:mt-3 [&_h2]:text-xs [&_h2]:font-semibold [&_h2]:uppercase [&_h2]:tracking-wide [&_h2]:text-theme-text-tertiary [&_h3]:text-sm [&_li]:text-theme-text-secondary [&_p]:my-1.5 [&_p]:text-theme-text-secondary">
2239
+ {diagnosis.report}
2240
+ </AIMarkdown>
2241
+ {assessmentSources}
2242
+ </div>
2243
+ </Collapse>
2244
+ </div>
2245
+ <div id={`${analysisId}-explanation`}>
2246
+ <Collapse open={detail === "explanation"} mountLazily>
2247
+ <div className="border-t border-theme-border/60 px-3 py-2">
2248
+ {explanation?.status === "running" ? (
2249
+ <div
2250
+ role="status"
2251
+ className="flex items-center gap-2 py-2 text-sm text-theme-text-secondary"
2252
+ >
2253
+ <Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
2254
+ Explaining this assessment…
2255
+ </div>
2256
+ ) : explanation?.status === "done" ? (
2257
+ <div className="flex items-start gap-2">
2258
+ <AIMarkdown className="min-w-0 flex-1 text-sm text-theme-text-secondary [overflow-wrap:anywhere]">
2259
+ {explanation.text || ""}
2260
+ </AIMarkdown>
2261
+ <CopyButton
2262
+ text={explanation.text || ""}
2263
+ label="Copy explanation"
2264
+ />
2265
+ </div>
2266
+ ) : explanation?.status === "error" ? (
2267
+ <div
2268
+ role="alert"
2269
+ className="flex flex-wrap items-center gap-2 py-2 text-sm text-theme-text-secondary"
2270
+ >
2271
+ <span>
2272
+ {explanation.error ||
2273
+ "The agent did not return an explanation."}
2274
+ </span>
2275
+ {explanation.onGenerate && (
2276
+ <button
2277
+ type="button"
2278
+ onClick={explanation.onGenerate}
2279
+ className="rounded-md px-2 py-1 text-xs font-medium text-accent-text hover:bg-theme-hover"
2280
+ >
2281
+ Try again
2282
+ </button>
2283
+ )}
2284
+ </div>
2285
+ ) : null}
2286
+ </div>
2287
+ </Collapse>
2288
+ </div>
2289
+ </div>
2290
+ </div>
2291
+ )}
1619
2292
 
1620
- {reveal === "full" && (
2293
+ {showConclusion && showDisclaimer && (
1621
2294
  <div className="flex items-start gap-1 px-0.5 text-[11px] text-theme-text-tertiary">
1622
2295
  <ShieldCheck className="mt-0.5 h-3 w-3 shrink-0" />
1623
2296
  <span>AI-generated — review before applying</span>
@@ -1627,30 +2300,124 @@ function DiagnosisResult({
1627
2300
  );
1628
2301
  }
1629
2302
 
1630
- function AllClearCard({ diagnosis }: { diagnosis: Diagnosis }) {
1631
- const text =
1632
- diagnosis.report || "No active problem found for this resource.";
2303
+ function AllClearCard({
2304
+ diagnosis,
2305
+ animate,
2306
+ showDisclaimer,
2307
+ coverageLimited,
2308
+ evidenceConflict,
2309
+ assessmentAction,
2310
+ assessmentSources,
2311
+ }: {
2312
+ diagnosis: Diagnosis;
2313
+ animate: boolean;
2314
+ showDisclaimer: boolean;
2315
+ coverageLimited: boolean;
2316
+ evidenceConflict: boolean;
2317
+ assessmentAction?: ReactNode;
2318
+ assessmentSources?: ReactNode;
2319
+ }) {
2320
+ const [showAnalysis, setShowAnalysis] = useState(false);
2321
+ const analysisReveal = useDisclosureReveal<HTMLDivElement>();
2322
+ const analysisId = useId();
2323
+ const report =
2324
+ diagnosis.report ||
2325
+ "The agent did not identify a problem in the evidence it checked.";
2326
+ const detailed = report.length > 320 || report.split("\n").length > 2;
2327
+ const summary = detailed
2328
+ ? "The agent found no active problem in the evidence it reviewed."
2329
+ : report;
1633
2330
  return (
1634
- <div className="mt-3 space-y-2 animate-result-in">
2331
+ <div className={`mt-3 space-y-2 ${animate ? "animate-result-in" : ""}`}>
1635
2332
  <div
1636
- className="rounded-lg border border-emerald-500/30 bg-emerald-500/5 p-3 animate-verdict-reveal"
1637
- style={{ "--glow": "rgb(16 185 129)" } as React.CSSProperties}
2333
+ className={`rounded-lg border p-3 ${
2334
+ evidenceConflict
2335
+ ? "border-amber-500/40 bg-amber-500/5"
2336
+ : coverageLimited
2337
+ ? "border-amber-500/30 bg-amber-500/5"
2338
+ : "border-emerald-500/30 bg-emerald-500/5"
2339
+ }`}
1638
2340
  >
1639
2341
  <div className="mb-1 flex items-center justify-between gap-2">
1640
- <div className="flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-emerald-500">
1641
- <CheckCircle2 className="h-3.5 w-3.5" />
1642
- No problems found
2342
+ <div
2343
+ className={`flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide ${
2344
+ evidenceConflict || coverageLimited
2345
+ ? "text-amber-500"
2346
+ : "text-emerald-500"
2347
+ }`}
2348
+ >
2349
+ {evidenceConflict || coverageLimited ? (
2350
+ <AlertTriangle className="h-3.5 w-3.5" />
2351
+ ) : (
2352
+ <CheckCircle2 className="h-3.5 w-3.5" />
2353
+ )}
2354
+ {evidenceConflict
2355
+ ? "Assessment conflicts with captured evidence"
2356
+ : coverageLimited
2357
+ ? "No problem identified in available evidence"
2358
+ : "No problem found in checked evidence"}
1643
2359
  </div>
1644
- <CopyButton text={text} />
2360
+ <CopyButton text={report} label="Copy assessment" />
1645
2361
  </div>
1646
2362
  <AIMarkdown className="text-sm text-theme-text-primary [overflow-wrap:anywhere] [&_code]:font-normal [&_li]:text-theme-text-primary [&_p]:my-1 [&_p]:text-theme-text-primary [&_p:first-child]:mt-0 [&_p:last-child]:mb-0">
1647
- {text}
2363
+ {summary}
1648
2364
  </AIMarkdown>
2365
+ {evidenceConflict ? (
2366
+ <p className="mt-2 text-xs text-theme-text-secondary">
2367
+ Radar also captured evidence of an active problem. Review that
2368
+ evidence before treating the agent&apos;s conclusion as an
2369
+ all-clear.
2370
+ </p>
2371
+ ) : coverageLimited ? (
2372
+ <p className="mt-2 text-xs text-theme-text-secondary">
2373
+ Evidence coverage is limited. Review the limitations in Evidence
2374
+ before treating this as an all-clear.
2375
+ </p>
2376
+ ) : null}
1649
2377
  </div>
1650
- <div className="flex items-start gap-1 px-0.5 text-[11px] text-theme-text-tertiary">
1651
- <ShieldCheck className="mt-0.5 h-3 w-3 shrink-0" />
1652
- <span>AI-generated — verify if symptoms persist</span>
1653
- </div>
2378
+ {detailed || assessmentAction || assessmentSources ? (
2379
+ <div>
2380
+ <div
2381
+ className="flex flex-wrap items-center gap-3 pt-2"
2382
+ data-assessment-actions
2383
+ >
2384
+ {(detailed || assessmentSources) && (
2385
+ <button
2386
+ type="button"
2387
+ aria-expanded={showAnalysis}
2388
+ aria-controls={analysisId}
2389
+ onClick={() => {
2390
+ setShowAnalysis(!showAnalysis);
2391
+ analysisReveal.revealAfterToggle(!showAnalysis);
2392
+ }}
2393
+ className="flex items-center gap-1.5 rounded-md py-2 text-xs font-medium text-theme-text-secondary hover:text-theme-text-primary"
2394
+ >
2395
+ <CollapseChevron open={showAnalysis} className="h-3.5 w-3.5" />
2396
+ {detailed ? "Full analysis" : "Assessment details"}
2397
+ </button>
2398
+ )}
2399
+ {assessmentAction && (
2400
+ <div className="ml-auto">{assessmentAction}</div>
2401
+ )}
2402
+ </div>
2403
+ <div id={analysisId} ref={analysisReveal.elementRef}>
2404
+ <Collapse open={showAnalysis}>
2405
+ <div className="border-t border-theme-border/60 px-3 py-2">
2406
+ <AIMarkdown className="text-sm [overflow-wrap:anywhere] [&_p]:my-1.5 [&_p]:text-theme-text-secondary [&_p:first-child]:mt-0 [&_p:last-child]:mb-0">
2407
+ {detailed ? report : ""}
2408
+ </AIMarkdown>
2409
+ {assessmentSources}
2410
+ </div>
2411
+ </Collapse>
2412
+ </div>
2413
+ </div>
2414
+ ) : null}
2415
+ {showDisclaimer ? (
2416
+ <div className="flex items-start gap-1 px-0.5 text-[11px] text-theme-text-tertiary">
2417
+ <ShieldCheck className="mt-0.5 h-3 w-3 shrink-0" />
2418
+ <span>AI-generated — verify if symptoms persist</span>
2419
+ </div>
2420
+ ) : null}
1654
2421
  </div>
1655
2422
  );
1656
2423
  }
@@ -1658,22 +2425,25 @@ function AllClearCard({ diagnosis }: { diagnosis: Diagnosis }) {
1658
2425
  // The agent investigated but couldn't determine an answer. A distinct, honest
1659
2426
  // state — neutral (not the alarming amber root cause, not the reassuring emerald
1660
2427
  // all-clear) — so "I couldn't tell" never reads as "you're fine."
1661
- function InconclusiveCard({ diagnosis }: { diagnosis: Diagnosis }) {
2428
+ function InconclusiveCard({
2429
+ diagnosis,
2430
+ animate,
2431
+ }: {
2432
+ diagnosis: Diagnosis;
2433
+ animate: boolean;
2434
+ }) {
1662
2435
  const text =
1663
2436
  diagnosis.report ||
1664
- "The investigation couldn't reach a clear conclusion — some checks were blocked or the evidence was ambiguous.";
2437
+ "The investigation couldn't reach a clear conclusion — some information was unavailable or the evidence was ambiguous.";
1665
2438
  return (
1666
- <div className="mt-3 space-y-2 animate-result-in">
1667
- <div
1668
- className="rounded-lg border border-theme-border bg-theme-elevated p-3 animate-verdict-reveal"
1669
- style={{ "--glow": "rgb(100 116 139)" } as React.CSSProperties}
1670
- >
2439
+ <div className={`mt-3 space-y-2 ${animate ? "animate-result-in" : ""}`}>
2440
+ <div className="rounded-lg border border-theme-border bg-theme-elevated p-3">
1671
2441
  <div className="mb-1 flex items-center justify-between gap-2">
1672
2442
  <div className="flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-theme-text-secondary">
1673
2443
  <HelpCircle className="h-3.5 w-3.5" />
1674
2444
  Couldn&apos;t determine
1675
2445
  </div>
1676
- <CopyButton text={text} />
2446
+ <CopyButton text={text} label="Copy assessment" />
1677
2447
  </div>
1678
2448
  <AIMarkdown className="text-sm text-theme-text-primary [overflow-wrap:anywhere] [&_code]:font-normal [&_li]:text-theme-text-primary [&_p]:my-1 [&_p]:text-theme-text-primary [&_p:first-child]:mt-0 [&_p:last-child]:mb-0">
1679
2449
  {text}
@@ -1682,7 +2452,8 @@ function InconclusiveCard({ diagnosis }: { diagnosis: Diagnosis }) {
1682
2452
  <div className="flex items-start gap-1 px-0.5 text-[11px] text-theme-text-tertiary">
1683
2453
  <ShieldCheck className="mt-0.5 h-3 w-3 shrink-0" />
1684
2454
  <span>
1685
- Try a follow-up with more detail, or re-run after granting access.
2455
+ Try a follow-up with more context, or investigate again after
2456
+ addressing any errors shown in Activity.
1686
2457
  </span>
1687
2458
  </div>
1688
2459
  </div>
@@ -1691,17 +2462,25 @@ function InconclusiveCard({ diagnosis }: { diagnosis: Diagnosis }) {
1691
2462
 
1692
2463
  // A follow-up reply: the agent answering a question, not re-diagnosing. Plain
1693
2464
  // neutral block — no root-cause anchor, no remediation/apply.
1694
- function FollowupAnswer({ diagnosis }: { diagnosis: Diagnosis }) {
2465
+ function FollowupAnswer({
2466
+ diagnosis,
2467
+ animate,
2468
+ }: {
2469
+ diagnosis: Diagnosis;
2470
+ animate: boolean;
2471
+ }) {
1695
2472
  const text = diagnosis.report || diagnosis.rootCause;
1696
2473
  if (!text) return null;
1697
2474
  return (
1698
- <div className="mt-1 rounded-lg border border-theme-border bg-theme-elevated p-3">
2475
+ <div
2476
+ className={`mt-1 rounded-lg border border-theme-border bg-theme-elevated p-3 ${animate ? "animate-result-in" : ""}`}
2477
+ >
1699
2478
  <div className="mb-1.5 flex items-center justify-between gap-2">
1700
2479
  <div className="flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-theme-text-tertiary">
1701
2480
  <Sparkles className="h-3.5 w-3.5 text-accent" />
1702
2481
  Answer
1703
2482
  </div>
1704
- <CopyButton text={text} />
2483
+ <CopyButton text={text} label="Copy answer" />
1705
2484
  </div>
1706
2485
  <AIMarkdown className="text-sm [overflow-wrap:anywhere] [&_code]:font-normal [&_h2:first-child]:mt-0 [&_h2]:mb-1.5 [&_h2]:mt-3 [&_h2]:text-xs [&_h2]:font-semibold [&_h2]:uppercase [&_h2]:tracking-wide [&_h2]:text-theme-text-tertiary [&_h3]:text-sm [&_li]:text-theme-text-secondary [&_p]:my-1.5 [&_p]:text-theme-text-secondary [&_p:first-child]:mt-0">
1707
2486
  {text}
@@ -1710,88 +2489,149 @@ function FollowupAnswer({ diagnosis }: { diagnosis: Diagnosis }) {
1710
2489
  );
1711
2490
  }
1712
2491
 
1713
- // A done turn that produced no renderable verdict at all (empty diagnosis, no
2492
+ // A done turn that produced no renderable result at all (empty diagnosis, no
1714
2493
  // narration). Without this the turn would render blank — which reads as "the tool
1715
2494
  // broke." Make the dead-end explicit and point at the recovery (a follow-up).
1716
- function EmptyResult() {
2495
+ function EmptyResult({ animate }: { animate: boolean }) {
1717
2496
  return (
1718
- <div className="mt-1 flex items-start gap-2 rounded-lg border border-theme-border bg-theme-elevated p-3 text-sm text-theme-text-secondary">
2497
+ <div
2498
+ className={`mt-1 flex items-start gap-2 rounded-lg border border-theme-border bg-theme-elevated p-3 text-sm text-theme-text-secondary ${animate ? "animate-result-in" : ""}`}
2499
+ >
1719
2500
  <ShieldCheck className="mt-0.5 h-4 w-4 shrink-0 text-theme-text-tertiary" />
1720
2501
  <span>
1721
- The investigation finished without a clear result. Try a follow-up
1722
- question, or re-run Diagnose.
2502
+ The investigation finished without a clear conclusion. Try a follow-up
2503
+ question, or investigate again.
1723
2504
  </span>
1724
2505
  </div>
1725
2506
  );
1726
2507
  }
1727
2508
 
1728
- // The result of an apply turn: a success confirmation of what changed, not a
1729
- // diagnosis. Emerald + checkmark so it reads as an outcome.
2509
+ // The result of an apply turn. Mutation truth comes from Radar write-tool
2510
+ // results, not the agent's prose or process exit. Missing outcome metadata is
2511
+ // therefore fail-closed as unknown; only explicit confirmation renders green.
1730
2512
  function ApplyOutcomeCard({
1731
2513
  diagnosis,
2514
+ error,
2515
+ applyOutcome,
1732
2516
  onCheckStatus,
2517
+ animate,
1733
2518
  }: {
1734
- diagnosis: Diagnosis;
2519
+ diagnosis: Diagnosis | null;
2520
+ error?: string | null;
2521
+ applyOutcome?: ApplyMutationOutcome;
1735
2522
  onCheckStatus?: () => void;
2523
+ animate: boolean;
1736
2524
  }) {
1737
- const outcome = diagnosis.report || diagnosis.rootCause;
2525
+ const outcome = diagnosis?.report || diagnosis?.rootCause;
2526
+ const status = applyOutcome ?? "unknown";
2527
+ const confirmed = status === "confirmed";
2528
+ const failed = status === "failed";
2529
+ const heading = confirmed
2530
+ ? "Applied"
2531
+ : failed
2532
+ ? "Not applied"
2533
+ : "Outcome unknown";
2534
+ const detail = error || outcome;
2535
+ const fallbackDetail = confirmed
2536
+ ? "Radar confirmed that the change was applied. Check the current state to confirm its effect."
2537
+ : failed
2538
+ ? "Radar could not confirm that the change was applied. Review Activity before retrying."
2539
+ : "Radar could not confirm whether the change was applied. Check the current state before retrying.";
2540
+ const containerClass = confirmed
2541
+ ? "border-emerald-500/30 bg-emerald-500/5"
2542
+ : failed
2543
+ ? "border-red-500/30 bg-red-500/5"
2544
+ : "border-amber-500/40 bg-amber-500/10";
2545
+ const accentClass = confirmed
2546
+ ? "text-emerald-500"
2547
+ : failed
2548
+ ? "text-red-400"
2549
+ : "text-amber-400";
2550
+ const buttonClass = confirmed
2551
+ ? "border-emerald-500/40 text-emerald-500 hover:bg-emerald-500/10"
2552
+ : "border-amber-500/40 text-amber-400 hover:bg-amber-500/10";
2553
+ const provenance = confirmed
2554
+ ? error
2555
+ ? "Radar confirmed the change — the agent report is incomplete"
2556
+ : "Radar confirmed the change was applied"
2557
+ : failed
2558
+ ? "Radar did not confirm that the change was applied"
2559
+ : "Radar cannot confirm whether the change was applied";
1738
2560
  return (
1739
- <div className="mt-3 space-y-2">
1740
- <div className="rounded-lg border border-emerald-500/30 bg-emerald-500/5 p-3">
2561
+ <div className={`mt-3 space-y-2 ${animate ? "animate-result-in" : ""}`}>
2562
+ <div className={`rounded-lg border p-3 ${containerClass}`}>
1741
2563
  <div className="mb-1 flex items-center justify-between gap-2">
1742
- <div className="flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-emerald-500">
1743
- <CheckCircle2 className="h-3.5 w-3.5" />
1744
- Applied
2564
+ <div
2565
+ className={`flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide ${accentClass}`}
2566
+ >
2567
+ {confirmed ? (
2568
+ <CheckCircle2 className="h-3.5 w-3.5" />
2569
+ ) : (
2570
+ <AlertTriangle className="h-3.5 w-3.5" />
2571
+ )}
2572
+ {heading}
1745
2573
  </div>
1746
- {outcome && <CopyButton text={outcome} />}
2574
+ {detail && <CopyButton text={detail} label="Copy apply result" />}
1747
2575
  </div>
1748
- {outcome && (
1749
- <AIMarkdown className="text-sm text-theme-text-primary [overflow-wrap:anywhere] [&_code]:font-normal [&_li]:text-theme-text-primary [&_p]:my-1 [&_p]:text-theme-text-primary [&_p:first-child]:mt-0 [&_p:last-child]:mb-0">
1750
- {outcome}
1751
- </AIMarkdown>
2576
+ <AIMarkdown className="text-sm text-theme-text-primary [overflow-wrap:anywhere] [&_code]:font-normal [&_li]:text-theme-text-primary [&_p]:my-1 [&_p]:text-theme-text-primary [&_p:first-child]:mt-0 [&_p:last-child]:mb-0">
2577
+ {detail || fallbackDetail}
2578
+ </AIMarkdown>
2579
+ {!confirmed && !failed && (
2580
+ <div className="mt-2 flex items-start gap-1.5 text-xs font-medium text-amber-300">
2581
+ <RefreshCw className="mt-0.5 h-3.5 w-3.5 shrink-0" />
2582
+ <span>
2583
+ Check the current state before trying to apply this change again.
2584
+ </span>
2585
+ </div>
1752
2586
  )}
1753
- {onCheckStatus && (
2587
+ {onCheckStatus && !failed && (
1754
2588
  <button
2589
+ type="button"
1755
2590
  onClick={onCheckStatus}
1756
- className="mt-3 flex w-full items-center justify-center gap-1.5 rounded-lg border border-emerald-500/40 py-2 text-sm font-medium text-emerald-500 hover:bg-emerald-500/10"
2591
+ className={`mt-3 flex w-full items-center justify-center gap-1.5 rounded-lg border py-2 text-sm font-medium ${buttonClass}`}
1757
2592
  >
1758
2593
  <RefreshCw className="h-4 w-4" />
1759
- Check status
2594
+ Check current status
1760
2595
  </button>
1761
2596
  )}
1762
2597
  </div>
1763
2598
  <div className="flex items-center gap-1 px-0.5 text-[11px] text-theme-text-tertiary">
1764
2599
  <ShieldCheck className="h-3 w-3 shrink-0" />
1765
- <span className="truncate">
1766
- Applied by AI — verify the change took effect
1767
- </span>
2600
+ <span className="truncate">{provenance}</span>
1768
2601
  </div>
1769
2602
  </div>
1770
2603
  );
1771
2604
  }
1772
2605
 
1773
- function CopyButton({ text }: { text: string }) {
2606
+ function CopyButton({ text, label }: { text: string; label: string }) {
1774
2607
  const [copied, setCopied] = useState(false);
1775
2608
  return (
1776
- <button
1777
- onClick={() => {
1778
- navigator.clipboard?.writeText(text);
1779
- setCopied(true);
1780
- setTimeout(() => setCopied(false), 1200);
1781
- }}
1782
- className="shrink-0 rounded p-1 text-theme-text-tertiary hover:bg-theme-hover hover:text-theme-text-primary"
1783
- aria-label="Copy"
2609
+ <Tooltip
2610
+ content={copied ? "Copied" : label}
2611
+ delay={100}
2612
+ wrapperClassName="shrink-0"
1784
2613
  >
1785
- {copied ? (
1786
- <Check className="h-3.5 w-3.5 text-emerald-400" />
1787
- ) : (
1788
- <Copy className="h-3.5 w-3.5" />
1789
- )}
1790
- </button>
2614
+ <button
2615
+ onClick={() => {
2616
+ navigator.clipboard?.writeText(text);
2617
+ setCopied(true);
2618
+ setTimeout(() => setCopied(false), 1200);
2619
+ }}
2620
+ className="shrink-0 rounded p-1 text-theme-text-tertiary hover:bg-theme-hover hover:text-theme-text-primary"
2621
+ aria-label={copied ? `${label} — copied` : label}
2622
+ aria-live="polite"
2623
+ >
2624
+ {copied ? (
2625
+ <Check className="h-3.5 w-3.5 text-emerald-400" />
2626
+ ) : (
2627
+ <Copy className="h-3.5 w-3.5" />
2628
+ )}
2629
+ </button>
2630
+ </Tooltip>
1791
2631
  );
1792
2632
  }
1793
2633
 
1794
- function prettyTool(tool: string): string {
2634
+ export function prettyTool(tool: string): string {
1795
2635
  return tool.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
1796
2636
  }
1797
2637
 
@@ -1803,42 +2643,6 @@ function confidenceLabel(c: number): string {
1803
2643
  return "Low";
1804
2644
  }
1805
2645
 
1806
- // Confidence shown as a band + three discrete pips (Low=1, Med=2, High=3 filled).
1807
- // Deliberately discrete, NOT a continuous bar: a filled fraction reads as a precise
1808
- // percentage, which is exactly the false calibration `confidenceLabel` exists to
1809
- // avoid (an LLM's two-sig-fig confidence isn't that precise). Accent-toned so it
1810
- // reads as "trust in the analysis," not problem severity.
1811
- function ConfidenceMeter({ value }: { value: number }) {
1812
- const band = confidenceLabel(value);
1813
- const filled = band === "High" ? 3 : band === "Medium" ? 2 : 1;
1814
- return (
1815
- <span className="flex items-center gap-1.5">
1816
- <span className="text-[11px] text-theme-text-tertiary">
1817
- {band} confidence
1818
- </span>
1819
- <span className="flex items-center gap-0.5" aria-hidden>
1820
- {[0, 1, 2].map((i) => (
1821
- <span
1822
- key={i}
1823
- className={`h-1 w-2.5 rounded-full ${i < filled ? "bg-accent" : "bg-theme-base"}`}
1824
- />
1825
- ))}
1826
- </span>
1827
- </span>
1828
- );
1829
- }
1830
-
1831
- // Shown when the model returned no confidence at all — so "unknown confidence"
1832
- // is visible rather than silently absent (which looks identical to high confidence
1833
- // minus the badge) on a trust-bearing surface.
1834
- function ConfidenceUnstated() {
1835
- return (
1836
- <span className="text-[11px] text-theme-text-tertiary">
1837
- Confidence not stated
1838
- </span>
1839
- );
1840
- }
1841
-
1842
2646
  // LLMs occasionally open a ```fence mid-line ("run this: ```bash kubectl …") or
1843
2647
  // put the command on the same line as the ```lang marker. GFM then won't parse
1844
2648
  // it as a fence — it leaks the literal ``` and renders an empty code box. Coerce