@skyhook-io/radar-app 1.12.3 → 1.13.1

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 (38) hide show
  1. package/package.json +5 -5
  2. package/src/App.tsx +37 -8
  3. package/src/RadarApp.tsx +1 -1
  4. package/src/api/client.ts +38 -6
  5. package/src/api/diagnose.ts +45 -5
  6. package/src/components/cost/ApplicationCostTab.test.ts +45 -0
  7. package/src/components/cost/ApplicationCostTab.tsx +115 -64
  8. package/src/components/cost/CostTrendChart.test.ts +65 -0
  9. package/src/components/cost/CostTrendChart.tsx +107 -17
  10. package/src/components/cost/CostView.test.ts +36 -1
  11. package/src/components/cost/CostView.tsx +133 -51
  12. package/src/components/cost/CurrentAllocationUse.tsx +8 -4
  13. package/src/components/cost/WorkloadCostTab.test.ts +50 -0
  14. package/src/components/cost/WorkloadCostTab.tsx +109 -61
  15. package/src/components/cost/source.test.ts +33 -0
  16. package/src/components/cost/source.ts +100 -0
  17. package/src/components/diagnose/AISettings.tsx +9 -4
  18. package/src/components/diagnose/DiagnoseContext.tsx +216 -48
  19. package/src/components/diagnose/DiagnoseSurface.test.tsx +118 -5
  20. package/src/components/diagnose/DiagnoseSurface.tsx +190 -32
  21. package/src/components/diagnose/Home.tsx +93 -64
  22. package/src/components/diagnose/InvestigationView.tsx +190 -124
  23. package/src/components/diagnose/parts.test.tsx +12 -4
  24. package/src/components/diagnose/parts.tsx +29 -17
  25. package/src/components/execution/BatchExecutionView.render.test.tsx +35 -0
  26. package/src/components/execution/BatchExecutionView.tsx +2 -3
  27. package/src/components/execution/execution-definition.test.ts +19 -0
  28. package/src/components/execution/execution-definition.ts +2 -0
  29. package/src/components/gitops/GitOpsView.tsx +25 -3
  30. package/src/components/home/CostCard.tsx +3 -2
  31. package/src/components/nav/navigation.test.ts +26 -0
  32. package/src/components/nav/navigation.ts +10 -0
  33. package/src/components/rightsizing/RightsizingScanView.tsx +36 -12
  34. package/src/components/rightsizing/copy.test.ts +19 -0
  35. package/src/components/settings/SettingsDialog.tsx +666 -103
  36. package/src/components/settings/settings-state.test.ts +42 -0
  37. package/src/components/settings/settings-state.ts +39 -0
  38. package/src/index.css +11 -1
@@ -34,6 +34,45 @@ import {
34
34
  const RECHECK_QUESTION =
35
35
  "Did the fix resolve the issue? Re-check the resource's current status and health now, and say whether it's healthy.";
36
36
 
37
+ export function canStopInvestigation(
38
+ run: RunSummary,
39
+ busy: boolean,
40
+ gone: boolean,
41
+ latestTurnStatus?: Turn["status"],
42
+ ): boolean {
43
+ // The transcript is fresher than the polled run summary. Once it has a
44
+ // terminal frame, a lagging/failed summary refresh must not resurrect Stop.
45
+ const transcriptTerminal =
46
+ latestTurnStatus === "done" || latestTurnStatus === "error";
47
+ return (
48
+ run.trigger !== "background" &&
49
+ run.status !== "stale" &&
50
+ run.status !== "stopping" &&
51
+ !gone &&
52
+ !transcriptTerminal &&
53
+ (busy || run.status === "running")
54
+ );
55
+ }
56
+
57
+ export function canContinueInvestigation(
58
+ run: RunSummary,
59
+ latestTurnStatus?: Turn["status"],
60
+ gone = false,
61
+ ): boolean {
62
+ const transcriptTerminal =
63
+ latestTurnStatus === "done" || latestTurnStatus === "error";
64
+ const summaryIsLaggingTerminalTranscript =
65
+ run.status === "running" &&
66
+ run.trigger !== "background" &&
67
+ transcriptTerminal;
68
+ return (
69
+ !gone &&
70
+ run.status !== "stale" &&
71
+ run.status !== "stopping" &&
72
+ (run.canContinue !== false || summaryIsLaggingTerminalTranscript)
73
+ );
74
+ }
75
+
37
76
  export function InvestigationView({
38
77
  run,
39
78
  agentLabel,
@@ -95,6 +134,14 @@ export function InvestigationView({
95
134
  // unfolds in beats: "rca" = root cause only (+ a "weighing remediation" beat),
96
135
  // "full" = everything. null/"full" for replayed turns (no choreography on rebuild).
97
136
  const [reveal, setReveal] = useState<"rca" | "full" | null>(null);
137
+ const latestTurnStatus = turns[turns.length - 1]?.status;
138
+ const canContinue = canContinueInvestigation(run, latestTurnStatus, gone);
139
+ // Continuation needs a resumable session, but stopping does not. A brand-new
140
+ // hosted turn can be running before the SDK has reported its session id, so
141
+ // keep Stop available for human investigations without advertising a
142
+ // follow-up composer that the server would reject. Automatic runs remain
143
+ // immutable even while their stream marks this view busy.
144
+ const canStop = canStopInvestigation(run, busy, gone, latestTurnStatus);
98
145
  const synthTimers = useRef<ReturnType<typeof setTimeout>[]>([]);
99
146
  const clearSynth = () => {
100
147
  synthTimers.current.forEach(clearTimeout);
@@ -216,6 +263,7 @@ export function InvestigationView({
216
263
  ...prev,
217
264
  {
218
265
  question: ev.question,
266
+ actor: ev.actor,
219
267
  timeline: [],
220
268
  diagnosis: null,
221
269
  error: null,
@@ -388,7 +436,7 @@ export function InvestigationView({
388
436
 
389
437
  const submitFollowup = () => {
390
438
  const q = input.trim();
391
- if (!q || busy || stale) return;
439
+ if (!q || busy || !canContinue) return;
392
440
  setInput("");
393
441
  setActionError(null);
394
442
  pinnedRef.current = true; // a user-initiated turn always follows to the bottom
@@ -401,7 +449,7 @@ export function InvestigationView({
401
449
  // Ask a canned follow-up (e.g. "explain simply") — a one-tap path that turns the
402
450
  // prompt's plain-language instruction into something the user controls.
403
451
  const askFollowup = (q: string) => {
404
- if (busy || stale) return;
452
+ if (busy || !canContinue) return;
405
453
  setActionError(null);
406
454
  pinnedRef.current = true;
407
455
  addTurn(run.id, { question: q }).catch((e) =>
@@ -423,10 +471,13 @@ export function InvestigationView({
423
471
  autoRecheckRef.current = true; // verify the write automatically once it lands
424
472
  addTurn(run.id, { apply: true, fix: pendingFix }).catch((e) => {
425
473
  autoRecheckRef.current = false; // the apply never started — don't auto-recheck
426
- setActionError(e instanceof DiagnoseError ? e.message : "Couldn't apply.");
474
+ setActionError(
475
+ e instanceof DiagnoseError ? e.message : "Couldn't apply.",
476
+ );
427
477
  });
428
478
  };
429
- const checkStatus = () => addTurn(run.id, { question: RECHECK_QUESTION }).catch(() => {});
479
+ const checkStatus = () =>
480
+ addTurn(run.id, { question: RECHECK_QUESTION }).catch(() => {});
430
481
 
431
482
  // Apply tracks the latest turn that produced remediation (so follow-ups don't
432
483
  // strip it) and is blocked on a stale (context-switched) run.
@@ -461,135 +512,140 @@ export function InvestigationView({
461
512
  return (
462
513
  <div className="relative flex min-h-0 flex-1 flex-col">
463
514
  <div className="flex min-h-0 flex-1">
464
- <div
465
- ref={scrollRef}
466
- onScroll={onScroll}
467
- className="flex-1 overflow-y-auto overflow-x-hidden px-4 py-3 [scrollbar-gutter:stable]"
468
- >
469
- <div className={maximized ? "mx-auto max-w-3xl" : ""}>
470
- <div className="space-y-4">
471
- {stale && (
472
- <div className="rounded-lg border border-amber-500/40 bg-amber-500/10 p-3 text-xs text-theme-text-secondary">
473
- <div className="flex items-start gap-2">
474
- <AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-500" />
475
- <span>
476
- This investigation ran against{" "}
477
- <span className="font-medium text-theme-text-primary">
478
- {run.context || "a different cluster"}
515
+ <div
516
+ ref={scrollRef}
517
+ onScroll={onScroll}
518
+ className="flex-1 overflow-y-auto overflow-x-hidden px-4 py-3 [scrollbar-gutter:stable]"
519
+ >
520
+ <div className={maximized ? "mx-auto max-w-3xl" : ""}>
521
+ <div className="space-y-4">
522
+ {stale && (
523
+ <div className="rounded-lg border border-amber-500/40 bg-amber-500/10 p-3 text-xs text-theme-text-secondary">
524
+ <div className="flex items-start gap-2">
525
+ <AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-500" />
526
+ <span>
527
+ This investigation ran against{" "}
528
+ <span className="font-medium text-theme-text-primary">
529
+ {run.context || "a different cluster"}
530
+ </span>
531
+ . The cluster context has changed — it's read-only now.
479
532
  </span>
480
- . The cluster context has changed — it's read-only now.
481
- </span>
533
+ </div>
534
+ <button
535
+ onClick={retryDiagnosis}
536
+ className="mt-2 inline-flex items-center gap-1.5 rounded-md border border-amber-500/50 px-2.5 py-1 font-medium text-amber-600 hover:bg-amber-500/10 dark:text-amber-400"
537
+ >
538
+ <Send className="h-3 w-3" />
539
+ Re-run on current cluster
540
+ </button>
482
541
  </div>
483
- <button
484
- onClick={retryDiagnosis}
485
- className="mt-2 inline-flex items-center gap-1.5 rounded-md border border-amber-500/50 px-2.5 py-1 font-medium text-amber-600 hover:bg-amber-500/10 dark:text-amber-400"
486
- >
487
- <Send className="h-3 w-3" />
488
- Re-run on current cluster
489
- </button>
490
- </div>
491
- )}
492
- {gone && turns.length === 0 && (
493
- <div className="rounded-lg border border-theme-border bg-theme-elevated p-3 text-sm text-theme-text-secondary">
494
- <div className="flex items-start gap-2">
495
- <AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-500" />
496
- <span>
497
- This investigation is no longer available history keeps
498
- the most recent investigations, and this one has been
499
- cleared. Re-run Diagnose to analyze the current cluster.
500
- </span>
542
+ )}
543
+ {gone && turns.length === 0 && (
544
+ <div className="rounded-lg border border-theme-border bg-theme-elevated p-3 text-sm text-theme-text-secondary">
545
+ <div className="flex items-start gap-2">
546
+ <AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-500" />
547
+ <span>
548
+ This investigation is unavailable. It may be private, your
549
+ access may have changed, or its history may have been
550
+ cleared. Check your account and organization, or ask the
551
+ creator for access.
552
+ </span>
553
+ </div>
554
+ <button
555
+ onClick={retryDiagnosis}
556
+ className="mt-2 inline-flex items-center gap-1.5 rounded-md border border-theme-border px-2.5 py-1 font-medium text-theme-text-primary hover:bg-theme-hover"
557
+ >
558
+ <Send className="h-3 w-3" />
559
+ Re-run Diagnose
560
+ </button>
501
561
  </div>
502
- <button
503
- onClick={retryDiagnosis}
504
- className="mt-2 inline-flex items-center gap-1.5 rounded-md border border-theme-border px-2.5 py-1 font-medium text-theme-text-primary hover:bg-theme-hover"
505
- >
506
- <Send className="h-3 w-3" />
507
- Re-run Diagnose
508
- </button>
509
- </div>
510
- )}
511
- <RunContextCard run={run} />
512
- {turns.map((t, i) => {
513
- const isLast = i === turns.length - 1;
514
- // Hosted runners are read-only the server refuses apply turns.
515
- const canApply = i === lastRemediationIdx && !stale && !hosted;
516
- const canCheck = isLast && t.status === "done" && !!t.apply;
517
- return (
518
- <TurnView
519
- key={i}
520
- turn={t}
521
- synthLabel={isLast ? synth : null}
522
- reveal={isLast ? (reveal ?? "full") : "full"}
523
- onApply={canApply ? requestApply : undefined}
524
- onAsk={isLast && !busy && !stale ? askFollowup : undefined}
525
- onCheckStatus={canCheck ? checkStatus : undefined}
526
- onRetryDiagnosis={
527
- isLast &&
528
- t.status === "error" &&
529
- !t.question &&
530
- !t.apply &&
531
- !stale
532
- ? retryDiagnosis
533
- : undefined
534
- }
535
- hideVerdict={pinned && i === pinnedIdx}
536
- />
537
- );
538
- })}
539
- {actionError && (
540
- <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">
541
- <AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-red-400" />
542
- <span>{actionError}</span>
543
- </div>
544
- )}
545
- {/* A start that failed belongs to the investigation that never began,
562
+ )}
563
+ <RunContextCard run={run} />
564
+ {turns.map((t, i) => {
565
+ const isLast = i === turns.length - 1;
566
+ // Hosted runners are read-only — the server refuses apply turns.
567
+ const canApply = i === lastRemediationIdx && !stale && !hosted;
568
+ const canCheck = isLast && t.status === "done" && !!t.apply;
569
+ return (
570
+ <TurnView
571
+ key={i}
572
+ turn={t}
573
+ synthLabel={isLast ? synth : null}
574
+ reveal={isLast ? (reveal ?? "full") : "full"}
575
+ onApply={canApply ? requestApply : undefined}
576
+ onAsk={
577
+ isLast && !busy && canContinue ? askFollowup : undefined
578
+ }
579
+ onCheckStatus={canCheck ? checkStatus : undefined}
580
+ onRetryDiagnosis={
581
+ isLast &&
582
+ t.status === "error" &&
583
+ !t.question &&
584
+ !t.apply &&
585
+ !stale
586
+ ? retryDiagnosis
587
+ : undefined
588
+ }
589
+ hideVerdict={pinned && i === pinnedIdx}
590
+ />
591
+ );
592
+ })}
593
+ {actionError && (
594
+ <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">
595
+ <AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-red-400" />
596
+ <span>{actionError}</span>
597
+ </div>
598
+ )}
599
+ {/* A start that failed belongs to the investigation that never began,
546
600
  not to the one on screen. Unlabelled at the foot of a finished
547
601
  transcript — verdict directly above — it reads as "this
548
602
  investigation failed". It also outlives the click that caused it,
549
603
  and this is the only place it surfaces while a run is focused, so
550
604
  it needs a way out. */}
551
- {startError && (
552
- <div
553
- role="alert"
554
- 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"
555
- >
556
- <AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-red-400" />
557
- <div className="min-w-0 flex-1">
558
- <div className="font-medium">
559
- Couldn&apos;t start a new investigation
605
+ {startError && (
606
+ <div
607
+ role="alert"
608
+ 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"
609
+ >
610
+ <AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-red-400" />
611
+ <div className="min-w-0 flex-1">
612
+ <div className="font-medium">
613
+ Couldn&apos;t start a new investigation
614
+ </div>
615
+ <div className="text-theme-text-secondary">
616
+ {startError}
617
+ </div>
560
618
  </div>
561
- <div className="text-theme-text-secondary">{startError}</div>
619
+ <button
620
+ onClick={dismissError}
621
+ className="shrink-0 rounded px-1.5 py-0.5 text-xs text-theme-text-tertiary hover:bg-theme-hover hover:text-theme-text-primary"
622
+ >
623
+ Dismiss
624
+ </button>
562
625
  </div>
563
- <button
564
- onClick={dismissError}
565
- className="shrink-0 rounded px-1.5 py-0.5 text-xs text-theme-text-tertiary hover:bg-theme-hover hover:text-theme-text-primary"
566
- >
567
- Dismiss
568
- </button>
569
- </div>
570
- )}
626
+ )}
627
+ </div>
571
628
  </div>
572
629
  </div>
573
- </div>
574
- {pinned && (
575
- <aside
576
- className={`w-[400px] shrink-0 overflow-y-auto border-l border-theme-border px-4 py-3 ${busy ? "opacity-70" : ""}`}
577
- >
578
- <div className="mb-1 text-[11px] font-medium uppercase tracking-wide text-theme-text-tertiary">
579
- {busy ? "Verdict · revising…" : "Verdict"}
580
- </div>
581
- <ResultCard
582
- diagnosis={turns[pinnedIdx].diagnosis!}
583
- onApply={
584
- pinnedIdx === lastRemediationIdx && !stale && !hosted
585
- ? requestApply
586
- : undefined
587
- }
588
- onAsk={!busy && !stale ? askFollowup : undefined}
589
- reveal="full"
590
- />
591
- </aside>
592
- )}
630
+ {pinned && (
631
+ <aside
632
+ className={`w-[400px] shrink-0 overflow-y-auto border-l border-theme-border px-4 py-3 ${busy ? "opacity-70" : ""}`}
633
+ >
634
+ <div className="mb-1 text-[11px] font-medium uppercase tracking-wide text-theme-text-tertiary">
635
+ {busy ? "Verdict · revising…" : "Verdict"}
636
+ </div>
637
+ <ResultCard
638
+ diagnosis={turns[pinnedIdx].diagnosis!}
639
+ onApply={
640
+ pinnedIdx === lastRemediationIdx && !stale && !hosted
641
+ ? requestApply
642
+ : undefined
643
+ }
644
+ onAsk={!busy && canContinue ? askFollowup : undefined}
645
+ reveal="full"
646
+ />
647
+ </aside>
648
+ )}
593
649
  </div>
594
650
 
595
651
  {showJump && (
@@ -616,13 +672,23 @@ export function InvestigationView({
616
672
  <div
617
673
  className={`border-t border-theme-border px-3 py-2.5 ${maximized ? "[&>*]:mx-auto [&>*]:max-w-3xl" : ""}`}
618
674
  >
619
- {busy ? (
675
+ {canStop ? (
620
676
  <button
621
677
  onClick={stop}
622
678
  className="w-full rounded-lg border border-theme-border py-1.5 text-sm text-theme-text-secondary hover:bg-theme-hover"
623
679
  >
624
680
  Stop
625
681
  </button>
682
+ ) : run.status === "stopping" ? (
683
+ <div className="rounded-lg border border-theme-border bg-theme-base px-3 py-2 text-xs text-theme-text-secondary">
684
+ Stopping investigation…
685
+ </div>
686
+ ) : !canContinue ? (
687
+ <div className="rounded-lg border border-theme-border bg-theme-base px-3 py-2 text-xs text-theme-text-secondary">
688
+ {run.trigger === "background"
689
+ ? "Automatic investigation · read-only. Start a new investigation on this resource to continue digging."
690
+ : "This investigation is read-only."}
691
+ </div>
626
692
  ) : (
627
693
  <div className="flex items-end gap-2">
628
694
  <textarea
@@ -635,7 +701,7 @@ export function InvestigationView({
635
701
  }
636
702
  }}
637
703
  rows={1}
638
- disabled={stale}
704
+ disabled={!canContinue || busy}
639
705
  placeholder={
640
706
  stale
641
707
  ? "Cluster changed — re-run Diagnose"
@@ -645,7 +711,7 @@ export function InvestigationView({
645
711
  />
646
712
  <button
647
713
  onClick={submitFollowup}
648
- disabled={!input.trim() || stale}
714
+ disabled={!input.trim() || !canContinue || busy}
649
715
  className="shrink-0 rounded-lg btn-brand p-2 disabled:opacity-40"
650
716
  aria-label="Send follow-up"
651
717
  >
@@ -41,9 +41,12 @@ describe("AgentControls execution profile explanation", () => {
41
41
  const html = renderAgent("cursor-agent", ["full-local"], "safeguarded");
42
42
  expect(html).toContain("must use this agent");
43
43
  expect(html).toContain("normal setup");
44
- expect(html).toContain("always loads your global MCP servers");
45
- expect(html).toContain("still enables the agent CLI");
46
- expect(html).toContain("does not constrain external MCP servers");
44
+ expect(html).toContain("--force");
45
+ expect(html).toContain("auto-approves its built-in tools");
46
+ expect(html).toContain("including your global servers");
47
+ expect(html).toContain("does not reliably confine those tools");
48
+ expect(html).not.toContain("still enables the agent CLI");
49
+ expect(html).not.toContain("does not constrain external MCP servers");
47
50
  expect(html).not.toContain("always runs this agent with safeguards");
48
51
  });
49
52
 
@@ -104,7 +107,12 @@ describe("ConsentCard execution profile treatment", () => {
104
107
  expect(html).toContain("border-amber-500/40");
105
108
  expect(html).toContain("text-amber-500");
106
109
  expect(html).toContain("Radar cannot constrain");
107
- expect(html).toContain("always loads your global MCP servers");
110
+ expect(html).toContain("--force");
111
+ expect(html).toContain("auto-approves its built-in tools");
112
+ expect(html).toContain("including your global servers");
113
+ expect(html).toContain("does not reliably confine those tools");
114
+ expect(html).not.toContain("still enables the agent CLI");
115
+ expect(html).not.toContain("does not constrain external MCP servers");
108
116
  expect(html).not.toContain("text-accent");
109
117
  });
110
118
 
@@ -33,6 +33,9 @@ import {
33
33
  import { StatusDot } from "@skyhook-io/k8s-ui";
34
34
  import { Markdown } from "../ui/Markdown";
35
35
 
36
+ const CURSOR_FULL_LOCAL_WARNING =
37
+ "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.";
38
+
36
39
  // Segmented two-or-more-way selector — shared shape for the agent and execution
37
40
  // profile pickers.
38
41
  function Segmented<T extends string | boolean>({
@@ -309,9 +312,11 @@ export function AgentControls({
309
312
  configured tools and MCP servers. Radar cannot constrain that
310
313
  external tooling; it may access local files or the network
311
314
  and may be able to change your cluster.{" "}
312
- {isClaude
313
- ? "Claude uses the permissions from your setup; Radar does not override them."
314
- : "Radar still enables the agent CLI’s own sandbox, but that sandbox does not constrain external MCP servers."}{" "}
315
+ {isCursor
316
+ ? CURSOR_FULL_LOCAL_WARNING
317
+ : isClaude
318
+ ? "Claude uses the permissions from your setup; Radar does not override them."
319
+ : "Radar still enables the agent CLI’s own sandbox, but that sandbox does not constrain external MCP servers."}{" "}
315
320
  Choose this only when you need that setup.
316
321
  </span>
317
322
  </div>
@@ -336,10 +341,14 @@ export function AgentControls({
336
341
  Radar must use this agent&apos;s normal setup. Radar cannot
337
342
  constrain its external tools or MCP servers; they may access
338
343
  local files or the network and may be able to change your
339
- cluster. Radar still enables the agent CLI&apos;s own sandbox,
340
- but that sandbox does not constrain external MCP servers.
341
- {isCursor &&
342
- " Cursor always loads your global MCP servers, so Radar cannot exclude them."}
344
+ cluster. {isCursor ? (
345
+ CURSOR_FULL_LOCAL_WARNING
346
+ ) : (
347
+ <>
348
+ Radar still enables the agent CLI&apos;s own sandbox, but that
349
+ sandbox does not constrain external MCP servers.
350
+ </>
351
+ )}
343
352
  </span>
344
353
  </div>
345
354
  )}
@@ -396,6 +405,7 @@ export function AgentControls({
396
405
  // or a follow-up, each with its own transcript + result.
397
406
  export type Turn = {
398
407
  question?: string;
408
+ actor?: string;
399
409
  timeline: TimelineItem[];
400
410
  diagnosis: Diagnosis | null;
401
411
  error: string | null;
@@ -500,8 +510,15 @@ export function TurnView({
500
510
  <div className="space-y-2">
501
511
  {turn.question && (
502
512
  <div className="flex justify-end">
503
- <div className="max-w-[85%] rounded-lg rounded-br-sm bg-accent/10 px-3 py-1.5 text-sm text-theme-text-primary [overflow-wrap:anywhere]">
504
- {turn.question}
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}
517
+ </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
+ </div>
505
522
  </div>
506
523
  </div>
507
524
  )}
@@ -832,7 +849,9 @@ export function ConsentCard({
832
849
  MCP servers. They may access local files or the network and may
833
850
  be able to change your cluster.
834
851
  </>,
835
- agent === "claude" ? (
852
+ agent === "cursor-agent" ? (
853
+ CURSOR_FULL_LOCAL_WARNING
854
+ ) : agent === "claude" ? (
836
855
  <>
837
856
  Claude uses the permissions from your setup; Radar does not
838
857
  override them.
@@ -841,13 +860,6 @@ export function ConsentCard({
841
860
  <>
842
861
  Radar still enables the agent CLI&apos;s own sandbox, but that
843
862
  sandbox does not constrain external MCP servers.
844
- {agent === "cursor-agent" && (
845
- <>
846
- {" "}
847
- Cursor always loads your global MCP servers; Radar cannot
848
- exclude them.
849
- </>
850
- )}
851
863
  </>
852
864
  ),
853
865
  ]
@@ -0,0 +1,35 @@
1
+ import { renderToStaticMarkup } from 'react-dom/server'
2
+ import { describe, expect, it, vi } from 'vitest'
3
+ import { BatchExecutionFullscreen } from './BatchExecutionView'
4
+
5
+ vi.mock('../../api/client', () => ({
6
+ useResource: () => ({}),
7
+ useWorkloadPods: () => ({}),
8
+ useWorkloadRuns: () => ({ data: { runs: [] } }),
9
+ }))
10
+
11
+ describe.each(['Job', 'CronJob', 'ScaledJob'])('%s cleanup retention display', (kind) => {
12
+ function render(ttlSecondsAfterFinished?: number) {
13
+ const jobSpec = { ttlSecondsAfterFinished, activeDeadlineSeconds: 60 }
14
+ const spec = kind === 'CronJob'
15
+ ? { jobTemplate: { spec: jobSpec } }
16
+ : kind === 'ScaledJob'
17
+ ? { jobTargetRef: jobSpec }
18
+ : jobSpec
19
+ return renderToStaticMarkup(
20
+ <BatchExecutionFullscreen kind={kind} apiKind={`${kind.toLowerCase()}s`} namespace="default" name="example" resource={{ spec }} />,
21
+ )
22
+ }
23
+
24
+ it.each([0, 300])('shows a configured TTL of %i seconds exactly once alongside the definition', (ttl) => {
25
+ const html = render(ttl)
26
+ expect(html.match(/TTL after finish/g)).toHaveLength(1)
27
+ expect(html).toContain(`>${ttl}s<`)
28
+ expect(html).toContain('>Deadline<')
29
+ expect(html).toContain('>60s<')
30
+ })
31
+
32
+ it('omits TTL when cleanup retention is unconfigured', () => {
33
+ expect(render()).not.toContain('TTL after finish')
34
+ })
35
+ })
@@ -475,9 +475,7 @@ function sourceFacts(kind: string, resource: any, runs: WorkloadRun[]) {
475
475
  progress: latest?.progress,
476
476
  duration: latest ? formatRunDuration(latest) : '',
477
477
  work: latest ? workCount(latest) : '',
478
- facts: [
479
- ['TTL after finish', spec.ttlSecondsAfterFinished != null ? `${spec.ttlSecondsAfterFinished}s` : 'None'],
480
- ],
478
+ facts: [],
481
479
  definition,
482
480
  }
483
481
  }
@@ -544,6 +542,7 @@ function ExecutionDefinitionDetails({ summary, namespace, compact = false }: { s
544
542
  <DefinitionFact label="Service account" value={summary.serviceAccount} mono />
545
543
  {summary.configMaps.length > 0 && <DefinitionFact label="ConfigMaps" value={summary.configMaps.join(', ')} mono />}
546
544
  {summary.secrets.length > 0 && <DefinitionFact label="Secrets" value={summary.secrets.join(', ')} mono />}
545
+ {summary.ttlAfterFinished && <DefinitionFact label="TTL after finish" value={summary.ttlAfterFinished} />}
547
546
  </>
548
547
  )}
549
548
  </div>
@@ -48,6 +48,25 @@ describe('executionDefinitionSummary', () => {
48
48
  })
49
49
  })
50
50
 
51
+ describe.each(['Job', 'CronJob', 'ScaledJob'])('%s retention', (kind) => {
52
+ function resource(jobSpec: Record<string, unknown>) {
53
+ if (kind === 'CronJob') return { spec: { jobTemplate: { spec: jobSpec } } }
54
+ if (kind === 'ScaledJob') return { spec: { jobTargetRef: jobSpec } }
55
+ return { spec: jobSpec }
56
+ }
57
+
58
+ it.each([0, 300])('keeps a configured TTL of %i seconds separate from the execution deadline', (ttl) => {
59
+ expect(executionDefinitionSummary(kind, resource({
60
+ ttlSecondsAfterFinished: ttl,
61
+ activeDeadlineSeconds: 60,
62
+ }))).toMatchObject({ ttlAfterFinished: `${ttl}s`, deadline: '60s' })
63
+ })
64
+
65
+ it('does not invent a TTL when cleanup retention is unconfigured', () => {
66
+ expect(executionDefinitionSummary(kind, resource({}))).not.toHaveProperty('ttlAfterFinished')
67
+ })
68
+ })
69
+
51
70
  it('explains Argo DAG shape, executable templates, policy, and dependencies', () => {
52
71
  const summary = executionDefinitionSummary('WorkflowTemplate', {
53
72
  spec: {
@@ -19,6 +19,7 @@ export interface ExecutionDefinitionSummary {
19
19
  serviceAccount: string
20
20
  retry: string
21
21
  deadline?: string
22
+ ttlAfterFinished?: string
22
23
  parallelism?: string
23
24
  }
24
25
 
@@ -79,6 +80,7 @@ function kubernetesJobSummary(jobSpec: any): ExecutionDefinitionSummary {
79
80
  serviceAccount: podSpec.serviceAccountName || 'default',
80
81
  retry: `Backoff limit ${jobSpec.backoffLimit ?? 6} · restart ${podSpec.restartPolicy || 'Never'}`,
81
82
  ...(jobSpec.activeDeadlineSeconds != null ? { deadline: `${jobSpec.activeDeadlineSeconds}s` } : {}),
83
+ ...(jobSpec.ttlSecondsAfterFinished != null ? { ttlAfterFinished: `${jobSpec.ttlSecondsAfterFinished}s` } : {}),
82
84
  parallelism: `${parallelism} parallel · ${completions} ${completions === 1 ? 'completion' : 'completions'} · ${completionMode}`,
83
85
  }
84
86
  }