@skyhook-io/radar-app 1.13.1 → 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 (47) hide show
  1. package/package.json +2 -2
  2. package/src/App.tsx +19 -1
  3. package/src/RadarApp.tsx +2 -2
  4. package/src/api/diagnose.test.ts +268 -0
  5. package/src/api/diagnose.ts +72 -9
  6. package/src/components/diagnose/AISettings.tsx +1 -1
  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 +12 -17
  10. package/src/components/diagnose/DiagnoseSurface.test.tsx +211 -16
  11. package/src/components/diagnose/DiagnoseSurface.tsx +464 -133
  12. package/src/components/diagnose/Home.test.tsx +293 -0
  13. package/src/components/diagnose/Home.tsx +289 -119
  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 +1900 -393
  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 +1337 -541
  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/home/MCPSetupDialog.tsx +2 -2
  40. package/src/components/home/mcpToolCatalog.test.ts +22 -0
  41. package/src/components/home/mcpToolCatalog.ts +3 -2
  42. package/src/components/issues/IssuesPane.tsx +5 -1
  43. package/src/components/settings/SettingsDialog.tsx +11 -13
  44. package/src/components/workload/WorkloadView.tsx +1 -1
  45. package/src/context/DiagnoseCustomization.tsx +11 -8
  46. package/src/index.css +63 -79
  47. 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,6 +427,8 @@ 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;
408
433
  actor?: string;
409
434
  timeline: TimelineItem[];
@@ -413,11 +438,21 @@ export type Turn = {
413
438
  // apply turns execute the recommended fix (write tools) — they report an
414
439
  // outcome, not a root cause, so the UI frames them differently.
415
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;
416
451
  };
417
452
 
418
453
  // TimelineItem is one ordered transcript entry: agent reasoning, or a tool call.
419
- type TimelineItem =
420
- | { kind: "thinking"; text: string }
454
+ export type TimelineItem =
455
+ | { kind: "thinking"; text: string; animate?: boolean }
421
456
  | {
422
457
  kind: "tool";
423
458
  id: string;
@@ -426,25 +461,66 @@ type TimelineItem =
426
461
  ms?: number;
427
462
  summary?: string;
428
463
  result?: string;
464
+ evidenceRef?: string;
465
+ radarEvidence?: boolean;
429
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;
430
473
  };
431
474
 
432
475
  export function appendThinking(
433
476
  prev: TimelineItem[],
434
477
  text: string,
478
+ animate = true,
435
479
  ): TimelineItem[] {
436
- const last = prev[prev.length - 1];
437
- if (last && last.kind === "thinking") {
438
- const next = [...prev];
439
- next[next.length - 1] = { ...last, text: (last.text + text).slice(-4000) };
440
- 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
+ });
441
516
  }
442
- return [...prev, { kind: "thinking", text }];
517
+ return next;
443
518
  }
444
519
 
445
520
  export function upsertTool(
446
521
  prev: TimelineItem[],
447
522
  step: DiagnoseStep,
523
+ animate = true,
448
524
  ): TimelineItem[] {
449
525
  const i = prev.findIndex((it) => it.kind === "tool" && it.id === step.id);
450
526
  if (i >= 0) {
@@ -457,201 +533,171 @@ export function upsertTool(
457
533
  kind: "tool",
458
534
  tool: step.tool || cur.tool,
459
535
  summary: step.summary || cur.summary,
536
+ animate: cur.animate === true || animate,
460
537
  };
461
538
  return next;
462
539
  }
463
- return [...prev, { kind: "tool", ...step }];
540
+ return [...prev, { kind: "tool", ...step, animate }];
464
541
  }
465
542
 
466
543
  export function TurnView({
467
544
  turn,
468
- synthLabel,
469
- reveal = "full",
470
545
  onApply,
471
- onAsk,
546
+ onViewExplanation,
472
547
  onCheckStatus,
473
548
  onRetryDiagnosis,
474
- hideVerdict = false,
549
+ hideConclusion = false,
550
+ turnIndex,
551
+ evidenceStepIds,
552
+ onViewEvidence,
553
+ sourceRevealRequest,
475
554
  }: {
476
555
  turn: Turn;
477
- synthLabel?: string | null;
478
- reveal?: "rca" | "full";
479
556
  onApply?: (fix: string) => void;
480
- onAsk?: (question: string) => void;
557
+ onViewExplanation?: () => void;
481
558
  onCheckStatus?: () => void;
482
559
  onRetryDiagnosis?: () => void;
483
- // 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,
484
561
  // so the transcript suppresses its own copy (reasoning + tool calls still show).
485
- 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
+ };
486
571
  }) {
487
572
  // A follow-up (a turn the user asked a question on) is a conversational reply,
488
573
  // not a fresh diagnosis — render it as a plain answer, never the root-cause
489
574
  // anchor or a remediation card.
490
- const followup = !!turn.question && !turn.apply;
575
+ const followup = !!turn.question && !turn.apply && !turn.verify;
491
576
  // Whether the done turn has anything for ResultCard to render — mirrors its
492
577
  // branch order exactly (apply → followup → structured/healthy), since a followup
493
578
  // ONLY ever renders FollowupAnswer (report/rootCause), never the remediation list.
494
579
  // When false, TurnView shows the narration or an explicit empty note, not a blank.
495
580
  const dx = turn.diagnosis;
496
- const hasVerdict = dx
497
- ? turn.apply
498
- ? true // ApplyOutcomeCard always renders an outcome
581
+ const hasResult = dx
582
+ ? followup
583
+ ? !!(dx.report?.trim() || dx.rootCause?.trim()) // FollowupAnswer
499
584
  : dx.healthy && !dx.rootCause
500
- ? true // AllClearCard (checked before followup in ResultCard)
585
+ ? true // AllClearCard
501
586
  : dx.inconclusive && !dx.rootCause
502
587
  ? true // InconclusiveCard
503
- : followup
504
- ? !!(dx.report?.trim() || dx.rootCause?.trim()) // FollowupAnswer
505
- : !!dx.rootCause ||
506
- (dx.remediation?.length ?? 0) > 0 ||
507
- !!dx.report?.trim()
588
+ : !!dx.rootCause ||
589
+ (dx.remediation?.length ?? 0) > 0 ||
590
+ !!dx.report?.trim()
508
591
  : false;
509
592
  return (
510
593
  <div className="space-y-2">
511
- {turn.question && (
512
- <div className="flex justify-end">
513
- <div className="max-w-[85%]">
514
- {turn.actor && (
515
- <div className="mb-0.5 text-right text-[10px] text-theme-text-tertiary">
516
- {turn.actor}
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>
619
+ </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}
517
630
  </div>
518
- )}
519
- <div className="rounded-lg rounded-br-sm bg-accent/10 px-3 py-1.5 text-sm text-theme-text-primary [overflow-wrap:anywhere]">
520
- {turn.question}
521
631
  </div>
522
632
  </div>
523
- </div>
633
+ ))
524
634
  )}
525
635
  <Timeline
526
636
  items={turn.timeline}
527
637
  running={turn.status === "running"}
528
638
  applyMode={turn.apply}
529
639
  followup={followup}
530
- synthLabel={synthLabel}
640
+ turnIndex={turnIndex}
641
+ evidenceStepIds={evidenceStepIds}
642
+ onViewEvidence={onViewEvidence}
643
+ sourceRevealRequest={sourceRevealRequest}
531
644
  />
532
- {turn.status === "done" &&
533
- (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 ? (
534
655
  <ResultCard
535
656
  diagnosis={turn.diagnosis!}
536
657
  onApply={onApply}
537
- onAsk={onAsk}
538
- apply={turn.apply}
539
658
  followup={followup}
540
- reveal={reveal}
541
659
  onCheckStatus={onCheckStatus}
660
+ animate={turn.animateResult !== false}
542
661
  />
543
662
  ) : (
544
- <EmptyResult />
663
+ <EmptyResult animate={turn.animateResult !== false} />
545
664
  ))}
546
- {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 ? (
547
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">
548
675
  <AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-red-400" />
549
676
  <div className="flex min-w-0 flex-col gap-2">
550
- <span className="whitespace-pre-wrap break-words">{turn.error}</span>
677
+ <span className="whitespace-pre-wrap break-words">
678
+ {turn.error}
679
+ </span>
551
680
  {onRetryDiagnosis && (
552
681
  <button
553
682
  type="button"
554
683
  onClick={onRetryDiagnosis}
555
684
  className="btn-brand self-start px-3 py-1 text-xs"
556
685
  >
557
- 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
558
696
  </button>
559
697
  )}
560
698
  </div>
561
699
  </div>
562
- )}
563
- </div>
564
- );
565
- }
566
-
567
- // RunContextCard opens every investigation with what RADAR already knows — the
568
- // health frame the server captured at run start. It renders instantly (no agent
569
- // round-trip), so the agent's boot time reads as "context, then deepening"
570
- // instead of dead air — and it anchors the verdict against Radar's own signal.
571
- function healthLineTone(severity?: string): "unhealthy" | "degraded" | "alert" {
572
- if (severity === "critical") return "unhealthy";
573
- if (severity === "warning") return "degraded";
574
- return "alert";
575
- }
576
-
577
- export function RunContextCard({ run }: { run: RunSummary }) {
578
- const h = run.health;
579
- const issueCount = h?.issueCount ?? 0;
580
- const issues = h?.issues ?? [];
581
- const findings = h?.auditFindings ?? [];
582
- const lines: ReactNode[] = [];
583
- if (issues.length > 0) {
584
- // The actual issue rows Radar's engine flagged — the reason bolded, the
585
- // engine's own detail sentence after it. Concrete beats a count.
586
- for (const [i, line] of issues.entries()) {
587
- lines.push(
588
- <div key={`issue-${i}`} className="flex items-start gap-1.5">
589
- <StatusDot
590
- tone={healthLineTone(line.severity)}
591
- className="mt-1 shrink-0"
592
- />
593
- <span className="min-w-0">
594
- <span className="font-medium text-theme-text-primary">
595
- {line.reason}
596
- </span>
597
- {line.message ? <> — {line.message}</> : null}
598
- </span>
599
- </div>,
600
- );
601
- }
602
- if (issueCount > issues.length) {
603
- lines.push(
604
- <div key="more" className="pl-3.5 text-theme-text-tertiary">
605
- +{issueCount - issues.length} more active issue
606
- {issueCount - issues.length === 1 ? "" : "s"}
607
- </div>,
608
- );
609
- }
610
- } else if (h?.health === "healthy") {
611
- lines.push(
612
- <div key="healthy" className="flex items-center gap-1.5">
613
- <StatusDot tone="healthy" className="shrink-0" />
614
- Reported healthy — 0 active issues
615
- </div>,
616
- );
617
- } else if (h) {
618
- lines.push(
619
- <div key="none" className="flex items-center gap-1.5">
620
- <StatusDot tone="unknown" className="shrink-0" />0 active issues
621
- {h.health ? ` — status ${h.health}` : ""}
622
- </div>,
623
- );
624
- }
625
- for (const [i, f] of findings.entries()) {
626
- lines.push(
627
- <div key={`audit-${i}`} className="pl-3.5 text-theme-text-tertiary">
628
- Audit: <span className="font-medium">{f.reason}</span>
629
- {f.message ? <> — {f.message}</> : null}
630
- </div>,
631
- );
632
- }
633
- if ((h?.auditCount ?? 0) > findings.length && findings.length > 0) {
634
- lines.push(
635
- <div key="audit-more" className="pl-3.5 text-theme-text-tertiary">
636
- +{h!.auditCount! - findings.length} more audit finding
637
- {h!.auditCount! - findings.length === 1 ? "" : "s"}
638
- </div>,
639
- );
640
- }
641
- if (run.managedBy) {
642
- lines.push(
643
- <div key="managed" className="pl-3.5 text-theme-text-tertiary">
644
- Managed by {run.managedBy}
645
- </div>,
646
- );
647
- }
648
- if (lines.length === 0) return null;
649
- return (
650
- <div className="rounded-md border border-theme-border/60 bg-theme-base/40 px-2.5 py-2">
651
- <div className="mb-1 text-[10px] font-semibold uppercase tracking-wide text-theme-text-tertiary">
652
- Radar&apos;s read at start
653
- </div>
654
- <div className="space-y-1 text-xs text-theme-text-secondary">{lines}</div>
700
+ ) : null}
655
701
  </div>
656
702
  );
657
703
  }
@@ -705,7 +751,9 @@ function ConsentCardShell({
705
751
  {title}
706
752
  </div>
707
753
  </div>
708
- <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>
709
757
  {bullets && bullets.length > 0 && (
710
758
  <ul className="mt-2 space-y-1 text-xs text-theme-text-tertiary">
711
759
  {bullets.map((b, i) => (
@@ -824,16 +872,16 @@ export function ConsentCard({
824
872
  ? [
825
873
  agent === "claude" ? (
826
874
  <>
827
- Radar safeguards disable Claude&apos;s built-in tools and limit
828
- MCP access to Radar&apos;s read-only investigation tools. Your
829
- Claude settings, hooks, and CLAUDE.md instructions still apply
830
- 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.
831
879
  </>
832
880
  ) : agent === "codex" ? (
833
881
  <>
834
- Radar safeguards exclude your Codex configuration and other MCP
835
- servers. Codex&apos;s sandboxed shell can still read files on
836
- 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.
837
885
  </>
838
886
  ) : (
839
887
  <>
@@ -845,9 +893,9 @@ export function ConsentCard({
845
893
  ]
846
894
  : [
847
895
  <>
848
- Radar cannot constrain the agent&apos;s other configured tools or
849
- MCP servers. They may access local files or the network and may
850
- 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.
851
899
  </>,
852
900
  agent === "cursor-agent" ? (
853
901
  CURSOR_FULL_LOCAL_WARNING
@@ -876,6 +924,7 @@ export function ApplyDialog({
876
924
  onConfirm,
877
925
  agentLabel,
878
926
  resourceLabel,
927
+ context,
879
928
  fix,
880
929
  managedBy,
881
930
  confidence,
@@ -885,6 +934,7 @@ export function ApplyDialog({
885
934
  onConfirm: () => void;
886
935
  agentLabel: string;
887
936
  resourceLabel: string;
937
+ context: string;
888
938
  fix?: string;
889
939
  managedBy?: string; // GitOps/Helm owner of the resource, if any
890
940
  confidence?: number;
@@ -903,8 +953,12 @@ export function ApplyDialog({
903
953
  }, [open]);
904
954
  const applyBlocked = !!managedBy && !acked;
905
955
  return (
906
- <DialogPortal open={open} onClose={onClose} className="max-w-lg w-full">
907
- <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">
908
962
  <div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-amber-500/20">
909
963
  <AlertTriangle className="h-5 w-5 text-amber-500" />
910
964
  </div>
@@ -919,22 +973,34 @@ export function ApplyDialog({
919
973
  </span>
920
974
  .
921
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
+ )}
922
987
  </div>
923
988
  </div>
924
989
 
925
- {fixText && (
926
- <div className="border-b border-theme-border p-4">
927
- <div className="mb-1.5 flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-accent">
928
- <Sparkles className="h-3.5 w-3.5" />
929
- 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>
930
1000
  </div>
931
- <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">
932
- {fixText}
933
- </AIMarkdown>
934
- </div>
935
- )}
936
-
937
- <div className="space-y-2 p-4">
1001
+ )}
1002
+ </div>
1003
+ <div className="shrink-0 space-y-2 p-4">
938
1004
  {/* The star warning: when we KNOW a controller owns this resource, a live
939
1005
  change reverts on the next reconcile — say so authoritatively. */}
940
1006
  {managedBy && (
@@ -964,7 +1030,7 @@ export function ApplyDialog({
964
1030
  <AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-theme-text-tertiary" />
965
1031
  <span>
966
1032
  The agent had <span className="font-medium">low confidence</span>{" "}
967
- in this diagnosis — consider asking a follow-up to verify before
1033
+ in this conclusion — consider asking a follow-up to verify before
968
1034
  applying.
969
1035
  </span>
970
1036
  </div>
@@ -978,8 +1044,7 @@ export function ApplyDialog({
978
1044
  </span>
979
1045
  </div>
980
1046
  </div>
981
-
982
- <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">
983
1048
  <button
984
1049
  onClick={onClose}
985
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"
@@ -1004,13 +1069,23 @@ export function Timeline({
1004
1069
  running,
1005
1070
  applyMode,
1006
1071
  followup,
1007
- synthLabel,
1072
+ turnIndex,
1073
+ evidenceStepIds,
1074
+ onViewEvidence,
1075
+ sourceRevealRequest,
1008
1076
  }: {
1009
1077
  items: TimelineItem[];
1010
1078
  running: boolean;
1011
1079
  applyMode?: boolean;
1012
1080
  followup?: boolean;
1013
- 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
+ };
1014
1089
  }) {
1015
1090
  const heading = applyMode
1016
1091
  ? "Applying fix"
@@ -1022,8 +1097,7 @@ export function Timeline({
1022
1097
  const activeTool = [...items]
1023
1098
  .reverse()
1024
1099
  .find((it) => it.kind === "tool" && it.status !== "done") as
1025
- | Extract<TimelineItem, { kind: "tool" }>
1026
- | undefined;
1100
+ Extract<TimelineItem, { kind: "tool" }> | undefined;
1027
1101
  const runningLabel = applyMode
1028
1102
  ? "Applying the fix…"
1029
1103
  : activeTool
@@ -1040,26 +1114,115 @@ export function Timeline({
1040
1114
  {heading}
1041
1115
  </div>
1042
1116
  )}
1043
- {items.map((it, i) =>
1044
- it.kind === "thinking" ? (
1045
- // The model's reasoning between tool calls — muted + subordinate to the
1046
- // tool rows. Rendered as markdown so Codex's summary headers read cleanly.
1047
- <AIMarkdown
1048
- key={i}
1049
- 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"
1050
- >
1051
- {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}
1052
1209
  </AIMarkdown>
1053
- ) : (
1054
- <ToolRow key={it.id} step={it} />
1055
- ),
1056
- )}
1057
- {running &&
1058
- (synthLabel ? (
1059
- <SynthBeat label={synthLabel} />
1060
- ) : (
1061
- <RunningStatus label={runningLabel} />
1062
- ))}
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}
1063
1226
  </div>
1064
1227
  );
1065
1228
  }
@@ -1107,17 +1270,6 @@ function RunningStatus({ label }: { label: string }) {
1107
1270
  );
1108
1271
  }
1109
1272
 
1110
- // A staged "thinking" beat — a calm breathing dot + shimmering label. Used both in
1111
- // the timeline (pre-verdict) and between the root-cause and remediation cards.
1112
- function SynthBeat({ label }: { label: string }) {
1113
- return (
1114
- <div className="flex items-center gap-2 pt-1 text-xs animate-transcript-enter">
1115
- <span className="h-1.5 w-1.5 shrink-0 rounded-full bg-accent animate-synth-pulse" />
1116
- <span className="ai-shimmer font-medium">{label}…</span>
1117
- </div>
1118
- );
1119
- }
1120
-
1121
1273
  // Maps a running tool to a human verb so the status line reads as activity, not
1122
1274
  // machinery. Falls back to the prettified tool name for anything unmapped.
1123
1275
  function toolActivity(tool: string): string {
@@ -1134,69 +1286,194 @@ function toolActivity(tool: string): string {
1134
1286
  return `${prettyTool(tool)}…`;
1135
1287
  }
1136
1288
 
1137
- function ToolRow({ step }: { step: Extract<TimelineItem, { kind: "tool" }> }) {
1138
- 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);
1139
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(":", "")}`;
1140
1316
  const hasDetail = !!(step.summary || step.result);
1317
+ useEffect(() => {
1318
+ if (revealRequestId !== undefined && hasDetail) setOpen(true);
1319
+ }, [hasDetail, revealRequestId]);
1141
1320
  // Offer the rich dialog when the result is structured or non-trivial in size.
1142
1321
  const richResult =
1143
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
+ );
1144
1370
  return (
1145
- <div className="animate-transcript-enter rounded-md border border-theme-border/60 bg-theme-base/40">
1146
- <button
1147
- onClick={() => hasDetail && setOpen((v) => !v)}
1148
- className={`flex w-full items-center gap-2 px-2 py-1.5 text-left text-sm ${
1149
- hasDetail ? "hover:bg-theme-hover" : "cursor-default"
1150
- }`}
1151
- >
1152
- {step.status === "done" ? (
1153
- <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>
1154
1401
  ) : (
1155
- <Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin text-accent" />
1156
- )}
1157
- <span className="font-mono text-xs text-theme-text-secondary">
1158
- {prettyTool(step.tool)}
1159
- </span>
1160
- {step.summary && (
1161
- <span className="min-w-0 flex-1 truncate font-mono text-[11px] text-theme-text-tertiary">
1162
- {compactArgs(step.summary)}
1163
- </span>
1164
- )}
1165
- {step.ms != null && (
1166
- <span className="ml-auto shrink-0 text-[11px] text-theme-text-tertiary">
1167
- {step.ms}ms
1168
- </span>
1169
- )}
1170
- {hasDetail && (
1171
- <ChevronRight
1172
- className={`h-3.5 w-3.5 shrink-0 text-theme-text-tertiary transition-transform ${open ? "rotate-90" : ""}`}
1173
- />
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>
1174
1405
  )}
1175
- </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>
1176
1417
  {hasDetail && (
1177
- <Collapse open={open}>
1178
- <div className="space-y-2 border-t border-theme-border/60 px-2 py-2">
1179
- {step.summary && <PayloadBlock label="Input" text={step.summary} />}
1180
- {step.result && (
1181
- <PayloadBlock
1182
- label="Result"
1183
- text={step.result}
1184
- truncated={step.truncated}
1185
- action={
1186
- 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}>
1187
1431
  <button
1188
- onClick={() => setShowFull(true)}
1189
- 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"
1190
1440
  >
1191
- <Maximize2 className="h-3 w-3" />
1192
- View payload
1441
+ <CollapseChevron
1442
+ open={argumentsOpen}
1443
+ className="h-3.5 w-3.5"
1444
+ />
1445
+ {argumentsOpen ? "Hide arguments" : "Show arguments"}
1193
1446
  </button>
1194
- ) : undefined
1195
- }
1196
- />
1197
- )}
1198
- </div>
1199
- </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>
1200
1477
  )}
1201
1478
  {step.result && (
1202
1479
  <ToolResultDialog
@@ -1235,13 +1512,36 @@ function PayloadBlock({
1235
1512
  text,
1236
1513
  truncated,
1237
1514
  action,
1515
+ sourceExcerpt,
1516
+ revealRequestId,
1238
1517
  }: {
1239
1518
  label: string;
1240
1519
  text: string;
1241
1520
  truncated?: boolean;
1242
1521
  action?: ReactNode;
1522
+ sourceExcerpt?: InvestigationSourceExcerpt;
1523
+ revealRequestId?: number;
1243
1524
  }) {
1244
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]);
1245
1545
  return (
1246
1546
  <div>
1247
1547
  <div className="mb-0.5 flex items-center justify-between gap-2">
@@ -1250,13 +1550,30 @@ function PayloadBlock({
1250
1550
  </span>
1251
1551
  <div className="flex items-center gap-2">
1252
1552
  {action}
1253
- <CopyButton text={json ?? text} />
1553
+ <CopyButton
1554
+ text={json ?? text}
1555
+ label={`Copy ${label.toLowerCase()}`}
1556
+ />
1254
1557
  </div>
1255
1558
  </div>
1256
1559
  <pre
1257
- 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]"}`}
1258
1562
  >
1259
- {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
+ )}
1260
1577
  </pre>
1261
1578
  {truncated && (
1262
1579
  <div className="mt-0.5 text-[10px] text-amber-500">
@@ -1345,7 +1662,7 @@ function ToolResultDialog({
1345
1662
  />
1346
1663
  </div>
1347
1664
  )}
1348
- <CopyButton text={display} />
1665
+ <CopyButton text={display} label="Copy tool result" />
1349
1666
  </div>
1350
1667
  </div>
1351
1668
  {html ? (
@@ -1370,18 +1687,6 @@ function safeYaml(value: unknown): string {
1370
1687
  }
1371
1688
  }
1372
1689
 
1373
- // Collapse — the Radar-standard expand/collapse motion (grid-template-rows
1374
- // 0fr↔1fr) used across issue rows. Children stay mounted so close animates too.
1375
- function Collapse({ open, children }: { open: boolean; children: ReactNode }) {
1376
- return (
1377
- <div
1378
- className={`issue-details-motion ${open ? "issue-details-motion-open" : ""}`}
1379
- >
1380
- <div className="overflow-hidden">{children}</div>
1381
- </div>
1382
- );
1383
- }
1384
-
1385
1690
  function compactArgs(raw: string): string {
1386
1691
  try {
1387
1692
  const o = JSON.parse(raw);
@@ -1396,71 +1701,271 @@ function compactArgs(raw: string): string {
1396
1701
  export function ResultCard({
1397
1702
  diagnosis,
1398
1703
  onApply,
1399
- onAsk,
1704
+ explanation,
1400
1705
  apply,
1706
+ applyOutcome,
1401
1707
  followup,
1402
- reveal = "full",
1708
+ section = "full",
1403
1709
  onCheckStatus,
1710
+ animate = true,
1711
+ showDisclaimer = true,
1712
+ coverageLimited = false,
1713
+ evidenceConflict = false,
1714
+ compactActions = false,
1715
+ assessmentAction,
1716
+ assessmentSources,
1717
+ actionNotice,
1404
1718
  }: {
1405
1719
  diagnosis: Diagnosis;
1406
1720
  onApply?: (fix: string) => void;
1407
- onAsk?: (question: string) => void;
1721
+ explanation?: AssessmentExplanation;
1408
1722
  apply?: boolean;
1723
+ applyOutcome?: ApplyMutationOutcome;
1409
1724
  followup?: boolean;
1410
- reveal?: "rca" | "full";
1725
+ section?: "full" | "conclusion" | "actions";
1411
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;
1412
1740
  }) {
1413
- // Apply turns report what changed — an outcome, not a diagnosis. Frame as a
1414
- // 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.
1415
1743
  if (apply)
1416
1744
  return (
1417
- <ApplyOutcomeCard diagnosis={diagnosis} onCheckStatus={onCheckStatus} />
1745
+ <ApplyOutcomeCard
1746
+ diagnosis={diagnosis}
1747
+ applyOutcome={applyOutcome}
1748
+ onCheckStatus={onCheckStatus}
1749
+ animate={animate}
1750
+ />
1418
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} />;
1419
1757
  if (diagnosis.healthy && !diagnosis.rootCause)
1420
- 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
+ );
1421
1769
  // Couldn't-determine is its own honest state — never a confident all-clear, never
1422
1770
  // the alarming root-cause anchor.
1423
1771
  if (diagnosis.inconclusive && !diagnosis.rootCause)
1424
- return <InconclusiveCard diagnosis={diagnosis} />;
1425
- // Follow-ups are conversational replies, not fresh diagnoses — plain answer.
1426
- if (followup) return <FollowupAnswer diagnosis={diagnosis} />;
1427
-
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
+ );
1428
1783
  // A turn with no structured root cause and no remediation (e.g. "looks healthy",
1429
1784
  // or a clarifying question) is not a diagnosis — render it neutrally rather than
1430
1785
  // forcing the alarming root-cause anchor onto a non-problem.
1431
1786
  const structured =
1432
1787
  !!diagnosis.rootCause || (diagnosis.remediation?.length ?? 0) > 0;
1433
- 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
+ );
1434
1800
 
1435
1801
  return (
1436
1802
  <DiagnosisResult
1437
1803
  diagnosis={diagnosis}
1438
1804
  onApply={onApply}
1439
- onAsk={onAsk}
1440
- 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}
1441
1813
  />
1442
1814
  );
1443
1815
  }
1444
1816
 
1445
- const EXPLAIN_SIMPLY_PROMPT =
1446
- "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
+ }
1447
1854
 
1448
- // The diagnosis result: root cause + remediation (any step applyable) + the
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
+ };
1910
+
1911
+ // The diagnosis result: likely cause + remediation + the
1449
1912
  // agent's full analysis on demand.
1450
1913
  function DiagnosisResult({
1451
1914
  diagnosis,
1452
1915
  onApply,
1453
- onAsk,
1454
- reveal = "full",
1916
+ explanation,
1917
+ section = "full",
1918
+ animate,
1919
+ showDisclaimer,
1920
+ compactActions,
1921
+ assessmentAction,
1922
+ assessmentSources,
1923
+ actionNotice,
1455
1924
  }: {
1456
1925
  diagnosis: Diagnosis;
1457
1926
  onApply?: (fix: string) => void;
1458
- onAsk?: (question: string) => void;
1459
- 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;
1460
1935
  }) {
1461
- const [showAnalysis, setShowAnalysis] = useState(false);
1462
- // Only a real structured root cause anchors the amber card; the full prose lives
1463
- // 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).
1464
1969
  const rootCause = diagnosis.rootCause;
1465
1970
  const remediation = diagnosis.remediation || [];
1466
1971
  const hasRemediation = remediation.length > 0;
@@ -1471,161 +1976,321 @@ function DiagnosisResult({
1471
1976
  // When it returns 0 / none ("needs human judgement"), we honor that and don't
1472
1977
  // offer one-click apply — the steps stay copy-only with a note.
1473
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
+ };
1474
2054
  return (
1475
- <div className="mt-3 space-y-2 animate-result-in">
1476
- {/* Root cause — the anchor: distinct tone + heavier type so it pops. */}
1477
- {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 && (
1478
2058
  <div
1479
- className="rounded-lg border border-amber-500/30 bg-amber-500/5 p-3 animate-verdict-reveal"
1480
- 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
+ }
1481
2064
  >
1482
- <div className="mb-1 flex items-center justify-between gap-2">
1483
- <div className="relative flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-amber-500">
1484
- <AlertTriangle className="h-3.5 w-3.5" />
1485
- Root cause
1486
- <span className="absolute -bottom-0.5 left-0 right-0 h-px bg-amber-500/60 animate-underline-sweep" />
1487
- </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
+ )}
1488
2077
  <div className="flex items-center gap-2">
1489
- {diagnosis.confidence != null ? (
1490
- <ConfidenceMeter value={diagnosis.confidence} />
1491
- ) : (
1492
- <ConfidenceUnstated />
1493
- )}
1494
- <CopyButton text={rootCause} />
2078
+ <CopyButton text={rootCause} label="Copy likely cause" />
1495
2079
  </div>
1496
2080
  </div>
1497
- <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
+ >
1498
2084
  {rootCause}
1499
2085
  </AIMarkdown>
1500
- {onAsk && reveal === "full" && (
1501
- <button
1502
- onClick={() => onAsk(EXPLAIN_SIMPLY_PROMPT)}
1503
- 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"
1504
- >
1505
- <HelpCircle className="h-3 w-3" />
1506
- Explain simply
1507
- </button>
1508
- )}
1509
2086
  </div>
1510
2087
  )}
1511
2088
 
1512
- {/* Between the root cause and the remediation, a beat lands where the steps
1513
- will appear — the verdict unfolds rather than dumping all at once. */}
1514
- {reveal === "rca" && hasRemediation && (
1515
- <SynthBeat label="Weighing remediation options" />
1516
- )}
1517
-
1518
- {/* Remediation — copyable steps; the recommended one is highlighted as the
1519
- default, and any step can be applied (Apply binds to that step's text). */}
1520
- {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 && (
1521
2092
  <div
1522
- className="rounded-lg border border-theme-border bg-theme-elevated p-3 animate-verdict-reveal"
1523
- 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
+ }
1524
2099
  >
1525
- <div className="mb-2 flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-theme-text-tertiary">
1526
- <Wrench className="h-3.5 w-3.5 text-accent" />
1527
- Remediation
1528
- </div>
1529
- <ol className="space-y-2">
1530
- {remediation.map((r, i) => {
1531
- const isRec = recValid && i === recIdx! - 1;
1532
- return (
1533
- <li
1534
- key={i}
1535
- className={`animate-transcript-enter ${
1536
- isRec
1537
- ? "rounded-lg border border-accent/40 bg-accent/5 p-2.5"
1538
- : ""
1539
- }`}
1540
- style={{ animationDelay: `${260 + i * 70}ms` }}
1541
- >
1542
- <div className="flex items-start gap-2">
1543
- <span
1544
- className={`mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-full text-[10px] ${
1545
- isRec
1546
- ? "bg-accent/20 text-accent"
1547
- : "bg-theme-base text-theme-text-tertiary"
1548
- }`}
1549
- >
1550
- {i + 1}
1551
- </span>
1552
- <div className="min-w-0 flex-1">
1553
- {isRec && (
1554
- <div className="mb-1">
1555
- <div className="flex items-center gap-1 text-[10px] font-semibold uppercase tracking-wide text-accent">
1556
- <Sparkles className="h-3 w-3" />
1557
- Recommended
1558
- </div>
1559
- {diagnosis.recommendedReason && (
1560
- <div className="mt-0.5 text-[11px] leading-snug text-theme-text-tertiary">
1561
- {diagnosis.recommendedReason}
1562
- </div>
1563
- )}
1564
- </div>
1565
- )}
1566
- <AIMarkdown className="text-sm [overflow-wrap:anywhere] [&_p]:my-0 [&_pre]:my-1.5">
1567
- {r}
1568
- </AIMarkdown>
1569
- </div>
1570
- {/* Action cluster: compact Apply (recommended = subtly
1571
- filled, others = ghost) sits next to Copy so each row's
1572
- actions stay together. The ellipsis signals a confirm
1573
- dialog follows — it doesn't apply immediately. */}
1574
- <div className="flex shrink-0 items-center gap-0.5">
1575
- {canApply && (
1576
- <button
1577
- onClick={() => onApply!(r)}
1578
- className={`inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium text-accent transition-colors ${
1579
- isRec
1580
- ? "border border-accent/40 bg-accent/10 hover:bg-accent/20"
1581
- : "hover:bg-accent/10"
1582
- }`}
1583
- >
1584
- <Wrench className="h-3 w-3" />
1585
- Apply…
1586
- </button>
1587
- )}
1588
- <CopyButton text={r} />
1589
- </div>
1590
- </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>
1591
2123
  </li>
1592
- );
1593
- })}
1594
- </ol>
1595
- {!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 && (
1596
2147
  <p className="mt-2 flex items-start gap-1.5 text-[11px] leading-snug text-theme-text-tertiary">
1597
2148
  <ShieldCheck className="mt-0.5 h-3 w-3 shrink-0" />
1598
- No safe one-click fix — the agent flagged this as needing your
1599
- judgement. Review the steps, or resume in your agent to apply them
1600
- interactively.
2149
+ No one-click fix is available. Review these steps and apply them
2150
+ manually, or ask the agent to continue.
1601
2151
  </p>
1602
2152
  )}
1603
2153
  </div>
1604
2154
  )}
1605
2155
 
1606
2156
  {/* Full analysis — the agent's detailed evidence, on demand. */}
1607
- {reveal === "full" && diagnosis.report && (
1608
- <div className="rounded-lg border border-theme-border bg-theme-elevated">
1609
- <button
1610
- onClick={() => setShowAnalysis((v) => !v)}
1611
- 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"
1612
- >
1613
- <ChevronRight
1614
- className={`h-3.5 w-3.5 transition-transform ${showAnalysis ? "rotate-90" : ""}`}
1615
- />
1616
- Full analysis
1617
- </button>
1618
- <Collapse open={showAnalysis}>
1619
- <div className="border-t border-theme-border/60 px-3 py-2">
1620
- <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">
1621
- {diagnosis.report}
1622
- </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
+ )}
1623
2226
  </div>
1624
- </Collapse>
1625
- </div>
1626
- )}
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
+ )}
1627
2292
 
1628
- {reveal === "full" && (
2293
+ {showConclusion && showDisclaimer && (
1629
2294
  <div className="flex items-start gap-1 px-0.5 text-[11px] text-theme-text-tertiary">
1630
2295
  <ShieldCheck className="mt-0.5 h-3 w-3 shrink-0" />
1631
2296
  <span>AI-generated — review before applying</span>
@@ -1635,30 +2300,124 @@ function DiagnosisResult({
1635
2300
  );
1636
2301
  }
1637
2302
 
1638
- function AllClearCard({ diagnosis }: { diagnosis: Diagnosis }) {
1639
- const text =
1640
- 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;
1641
2330
  return (
1642
- <div className="mt-3 space-y-2 animate-result-in">
2331
+ <div className={`mt-3 space-y-2 ${animate ? "animate-result-in" : ""}`}>
1643
2332
  <div
1644
- className="rounded-lg border border-emerald-500/30 bg-emerald-500/5 p-3 animate-verdict-reveal"
1645
- 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
+ }`}
1646
2340
  >
1647
2341
  <div className="mb-1 flex items-center justify-between gap-2">
1648
- <div className="flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-emerald-500">
1649
- <CheckCircle2 className="h-3.5 w-3.5" />
1650
- 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"}
1651
2359
  </div>
1652
- <CopyButton text={text} />
2360
+ <CopyButton text={report} label="Copy assessment" />
1653
2361
  </div>
1654
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">
1655
- {text}
2363
+ {summary}
1656
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}
1657
2377
  </div>
1658
- <div className="flex items-start gap-1 px-0.5 text-[11px] text-theme-text-tertiary">
1659
- <ShieldCheck className="mt-0.5 h-3 w-3 shrink-0" />
1660
- <span>AI-generated — verify if symptoms persist</span>
1661
- </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}
1662
2421
  </div>
1663
2422
  );
1664
2423
  }
@@ -1666,22 +2425,25 @@ function AllClearCard({ diagnosis }: { diagnosis: Diagnosis }) {
1666
2425
  // The agent investigated but couldn't determine an answer. A distinct, honest
1667
2426
  // state — neutral (not the alarming amber root cause, not the reassuring emerald
1668
2427
  // all-clear) — so "I couldn't tell" never reads as "you're fine."
1669
- function InconclusiveCard({ diagnosis }: { diagnosis: Diagnosis }) {
2428
+ function InconclusiveCard({
2429
+ diagnosis,
2430
+ animate,
2431
+ }: {
2432
+ diagnosis: Diagnosis;
2433
+ animate: boolean;
2434
+ }) {
1670
2435
  const text =
1671
2436
  diagnosis.report ||
1672
- "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.";
1673
2438
  return (
1674
- <div className="mt-3 space-y-2 animate-result-in">
1675
- <div
1676
- className="rounded-lg border border-theme-border bg-theme-elevated p-3 animate-verdict-reveal"
1677
- style={{ "--glow": "rgb(100 116 139)" } as React.CSSProperties}
1678
- >
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">
1679
2441
  <div className="mb-1 flex items-center justify-between gap-2">
1680
2442
  <div className="flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-theme-text-secondary">
1681
2443
  <HelpCircle className="h-3.5 w-3.5" />
1682
2444
  Couldn&apos;t determine
1683
2445
  </div>
1684
- <CopyButton text={text} />
2446
+ <CopyButton text={text} label="Copy assessment" />
1685
2447
  </div>
1686
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">
1687
2449
  {text}
@@ -1690,7 +2452,8 @@ function InconclusiveCard({ diagnosis }: { diagnosis: Diagnosis }) {
1690
2452
  <div className="flex items-start gap-1 px-0.5 text-[11px] text-theme-text-tertiary">
1691
2453
  <ShieldCheck className="mt-0.5 h-3 w-3 shrink-0" />
1692
2454
  <span>
1693
- 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.
1694
2457
  </span>
1695
2458
  </div>
1696
2459
  </div>
@@ -1699,17 +2462,25 @@ function InconclusiveCard({ diagnosis }: { diagnosis: Diagnosis }) {
1699
2462
 
1700
2463
  // A follow-up reply: the agent answering a question, not re-diagnosing. Plain
1701
2464
  // neutral block — no root-cause anchor, no remediation/apply.
1702
- function FollowupAnswer({ diagnosis }: { diagnosis: Diagnosis }) {
2465
+ function FollowupAnswer({
2466
+ diagnosis,
2467
+ animate,
2468
+ }: {
2469
+ diagnosis: Diagnosis;
2470
+ animate: boolean;
2471
+ }) {
1703
2472
  const text = diagnosis.report || diagnosis.rootCause;
1704
2473
  if (!text) return null;
1705
2474
  return (
1706
- <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
+ >
1707
2478
  <div className="mb-1.5 flex items-center justify-between gap-2">
1708
2479
  <div className="flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-theme-text-tertiary">
1709
2480
  <Sparkles className="h-3.5 w-3.5 text-accent" />
1710
2481
  Answer
1711
2482
  </div>
1712
- <CopyButton text={text} />
2483
+ <CopyButton text={text} label="Copy answer" />
1713
2484
  </div>
1714
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">
1715
2486
  {text}
@@ -1718,88 +2489,149 @@ function FollowupAnswer({ diagnosis }: { diagnosis: Diagnosis }) {
1718
2489
  );
1719
2490
  }
1720
2491
 
1721
- // 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
1722
2493
  // narration). Without this the turn would render blank — which reads as "the tool
1723
2494
  // broke." Make the dead-end explicit and point at the recovery (a follow-up).
1724
- function EmptyResult() {
2495
+ function EmptyResult({ animate }: { animate: boolean }) {
1725
2496
  return (
1726
- <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
+ >
1727
2500
  <ShieldCheck className="mt-0.5 h-4 w-4 shrink-0 text-theme-text-tertiary" />
1728
2501
  <span>
1729
- The investigation finished without a clear result. Try a follow-up
1730
- question, or re-run Diagnose.
2502
+ The investigation finished without a clear conclusion. Try a follow-up
2503
+ question, or investigate again.
1731
2504
  </span>
1732
2505
  </div>
1733
2506
  );
1734
2507
  }
1735
2508
 
1736
- // The result of an apply turn: a success confirmation of what changed, not a
1737
- // 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.
1738
2512
  function ApplyOutcomeCard({
1739
2513
  diagnosis,
2514
+ error,
2515
+ applyOutcome,
1740
2516
  onCheckStatus,
2517
+ animate,
1741
2518
  }: {
1742
- diagnosis: Diagnosis;
2519
+ diagnosis: Diagnosis | null;
2520
+ error?: string | null;
2521
+ applyOutcome?: ApplyMutationOutcome;
1743
2522
  onCheckStatus?: () => void;
2523
+ animate: boolean;
1744
2524
  }) {
1745
- 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";
1746
2560
  return (
1747
- <div className="mt-3 space-y-2">
1748
- <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}`}>
1749
2563
  <div className="mb-1 flex items-center justify-between gap-2">
1750
- <div className="flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-emerald-500">
1751
- <CheckCircle2 className="h-3.5 w-3.5" />
1752
- 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}
1753
2573
  </div>
1754
- {outcome && <CopyButton text={outcome} />}
2574
+ {detail && <CopyButton text={detail} label="Copy apply result" />}
1755
2575
  </div>
1756
- {outcome && (
1757
- <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">
1758
- {outcome}
1759
- </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>
1760
2586
  )}
1761
- {onCheckStatus && (
2587
+ {onCheckStatus && !failed && (
1762
2588
  <button
2589
+ type="button"
1763
2590
  onClick={onCheckStatus}
1764
- 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}`}
1765
2592
  >
1766
2593
  <RefreshCw className="h-4 w-4" />
1767
- Check status
2594
+ Check current status
1768
2595
  </button>
1769
2596
  )}
1770
2597
  </div>
1771
2598
  <div className="flex items-center gap-1 px-0.5 text-[11px] text-theme-text-tertiary">
1772
2599
  <ShieldCheck className="h-3 w-3 shrink-0" />
1773
- <span className="truncate">
1774
- Applied by AI — verify the change took effect
1775
- </span>
2600
+ <span className="truncate">{provenance}</span>
1776
2601
  </div>
1777
2602
  </div>
1778
2603
  );
1779
2604
  }
1780
2605
 
1781
- function CopyButton({ text }: { text: string }) {
2606
+ function CopyButton({ text, label }: { text: string; label: string }) {
1782
2607
  const [copied, setCopied] = useState(false);
1783
2608
  return (
1784
- <button
1785
- onClick={() => {
1786
- navigator.clipboard?.writeText(text);
1787
- setCopied(true);
1788
- setTimeout(() => setCopied(false), 1200);
1789
- }}
1790
- className="shrink-0 rounded p-1 text-theme-text-tertiary hover:bg-theme-hover hover:text-theme-text-primary"
1791
- aria-label="Copy"
2609
+ <Tooltip
2610
+ content={copied ? "Copied" : label}
2611
+ delay={100}
2612
+ wrapperClassName="shrink-0"
1792
2613
  >
1793
- {copied ? (
1794
- <Check className="h-3.5 w-3.5 text-emerald-400" />
1795
- ) : (
1796
- <Copy className="h-3.5 w-3.5" />
1797
- )}
1798
- </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>
1799
2631
  );
1800
2632
  }
1801
2633
 
1802
- function prettyTool(tool: string): string {
2634
+ export function prettyTool(tool: string): string {
1803
2635
  return tool.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
1804
2636
  }
1805
2637
 
@@ -1811,42 +2643,6 @@ function confidenceLabel(c: number): string {
1811
2643
  return "Low";
1812
2644
  }
1813
2645
 
1814
- // Confidence shown as a band + three discrete pips (Low=1, Med=2, High=3 filled).
1815
- // Deliberately discrete, NOT a continuous bar: a filled fraction reads as a precise
1816
- // percentage, which is exactly the false calibration `confidenceLabel` exists to
1817
- // avoid (an LLM's two-sig-fig confidence isn't that precise). Accent-toned so it
1818
- // reads as "trust in the analysis," not problem severity.
1819
- function ConfidenceMeter({ value }: { value: number }) {
1820
- const band = confidenceLabel(value);
1821
- const filled = band === "High" ? 3 : band === "Medium" ? 2 : 1;
1822
- return (
1823
- <span className="flex items-center gap-1.5">
1824
- <span className="text-[11px] text-theme-text-tertiary">
1825
- {band} confidence
1826
- </span>
1827
- <span className="flex items-center gap-0.5" aria-hidden>
1828
- {[0, 1, 2].map((i) => (
1829
- <span
1830
- key={i}
1831
- className={`h-1 w-2.5 rounded-full ${i < filled ? "bg-accent" : "bg-theme-base"}`}
1832
- />
1833
- ))}
1834
- </span>
1835
- </span>
1836
- );
1837
- }
1838
-
1839
- // Shown when the model returned no confidence at all — so "unknown confidence"
1840
- // is visible rather than silently absent (which looks identical to high confidence
1841
- // minus the badge) on a trust-bearing surface.
1842
- function ConfidenceUnstated() {
1843
- return (
1844
- <span className="text-[11px] text-theme-text-tertiary">
1845
- Confidence not stated
1846
- </span>
1847
- );
1848
- }
1849
-
1850
2646
  // LLMs occasionally open a ```fence mid-line ("run this: ```bash kubectl …") or
1851
2647
  // put the command on the same line as the ```lang marker. GFM then won't parse
1852
2648
  // it as a fence — it leaks the literal ``` and renders an empty code box. Coerce