@skyhook-io/radar-app 1.9.5 → 1.9.7

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.
@@ -1,6 +1,7 @@
1
- import { useEffect, useState } from 'react'
1
+ import { useEffect, useId, useState, type ReactNode } from 'react'
2
2
  import { useMutation, useQueryClient } from '@tanstack/react-query'
3
3
  import { Bell, Check, Globe, History, Sparkles, Users, X } from 'lucide-react'
4
+ import { Collapse, CollapseChevron } from '@skyhook-io/k8s-ui/components/ui/Collapse'
4
5
  import { DialogPortal } from '@skyhook-io/k8s-ui/components/ui/DialogPortal'
5
6
  import { Tooltip } from './ui/Tooltip'
6
7
  import { CloudConnectFlow } from './CloudConnectFlow'
@@ -34,6 +35,9 @@ const FALLBACK_APP_URL = 'https://app.radarhq.io'
34
35
  // offline laptop all render exactly what Radar rendered before this fetch
35
36
  // existed — the dialog never waits on the network and never shows a gap.
36
37
  const DEFAULT_ASSURANCES = ['Free for 3 clusters', 'No credit card', 'Your cluster data stays in your cluster']
38
+ // A product fact, not funnel copy — appended even when the Hub supplies its
39
+ // own assurances (deduped if the Hub starts sending it).
40
+ const SOC2_ASSURANCE = 'SOC 2 compliant'
37
41
  const SIGNUP_QUERY = '?utm_source=radar-oss&utm_medium=app&utm_campaign=cloud-modal'
38
42
  const ABOUT_URL = 'https://radarhq.io/about'
39
43
  const SELF_HOSTED_DOCS_URL = 'https://radarhq.io/docs/cloud/self-hosted/'
@@ -193,7 +197,7 @@ export function CloudFunnelButton() {
193
197
  <DialogPortal
194
198
  open={open}
195
199
  onClose={() => setOpen(false)}
196
- className="w-[500px] max-w-full max-h-[calc(100vh-2rem)] overflow-hidden flex flex-col"
200
+ className="w-[580px] max-w-full max-h-[calc(100vh-2rem)] overflow-hidden flex flex-col"
197
201
  >
198
202
  <button
199
203
  onClick={() => setOpen(false)}
@@ -208,7 +212,7 @@ export function CloudFunnelButton() {
208
212
  more with the connect flow, whose plan card is the tallest state. */}
209
213
  {showFlow && flowForView ? (
210
214
  <div className="min-h-0 overflow-y-auto">
211
- <div className="px-7 pt-6">
215
+ <div className="px-8 pt-7">
212
216
  <Eyebrow />
213
217
  </div>
214
218
  <CloudConnectFlow
@@ -245,6 +249,39 @@ export function CloudFunnelButton() {
245
249
  )
246
250
  }
247
251
 
252
+ // Secondary copy the pitch shouldn't spend vertical space on until asked for.
253
+ function Fold({ summary, className = '', children }: { summary: string; className?: string; children: ReactNode }) {
254
+ const [open, setOpen] = useState(false)
255
+ const bodyId = useId()
256
+ return (
257
+ <div className={className}>
258
+ <button
259
+ type="button"
260
+ onClick={() => setOpen((v) => !v)}
261
+ aria-expanded={open}
262
+ aria-controls={bodyId}
263
+ className="flex items-center gap-1.5 text-[11.5px] text-theme-text-tertiary hover:text-theme-text-primary transition-colors"
264
+ >
265
+ <CollapseChevron open={open} className="w-3 h-3" />
266
+ {summary}
267
+ </button>
268
+ <Collapse open={open}>
269
+ <p id={bodyId} className="mt-2 pl-[18px] text-[11.5px] leading-relaxed text-theme-text-tertiary">
270
+ {children}
271
+ </p>
272
+ </Collapse>
273
+ </div>
274
+ )
275
+ }
276
+
277
+ function assuranceItems(fromHub?: string[]): string[] {
278
+ const items = fromHub?.length ? fromHub : DEFAULT_ASSURANCES
279
+ if (items.some((item) => item.toLowerCase().includes('soc 2'))) return items
280
+ // Before the last item: the closer ("data stays in your cluster") is the
281
+ // longest line and wraps most naturally when it comes last.
282
+ return [...items.slice(0, -1), SOC2_ASSURANCE, items[items.length - 1]]
283
+ }
284
+
248
285
  function RadarSweep() {
249
286
  return (
250
287
  <div
@@ -263,32 +300,13 @@ function RadarSweep() {
263
300
 
264
301
  function Eyebrow() {
265
302
  return (
266
- <div className="flex items-center gap-2.5 mb-3.5">
303
+ <div className="flex items-center gap-3 mb-5">
267
304
  <RadarSweep />
268
305
  <span className="font-mono text-[10.5px] tracking-[0.16em] uppercase text-emerald-600 dark:text-emerald-400">Radar Cloud</span>
269
306
  </div>
270
307
  )
271
308
  }
272
309
 
273
- function Faces() {
274
- return (
275
- <div className="flex shrink-0" aria-hidden>
276
- {[
277
- ['R', 'bg-emerald-500/20 text-emerald-700 dark:text-emerald-300'],
278
- ['N', 'bg-sky-500/20 text-sky-700 dark:text-sky-300'],
279
- ['E', 'bg-amber-500/20 text-amber-700 dark:text-amber-300'],
280
- ].map(([initial, color], i) => (
281
- <div
282
- key={initial}
283
- className={`w-6 h-6 text-[10px] ${color} rounded-full grid place-items-center font-bold border-2 border-theme-surface ${i > 0 ? '-ml-1.5' : ''}`}
284
- >
285
- {initial}
286
- </div>
287
- ))}
288
- </div>
289
- )
290
- }
291
-
292
310
  function ModalFooter({
293
311
  lane,
294
312
  signupUrl,
@@ -328,9 +346,9 @@ function ModalFooter({
328
346
  // fast click would escape to signup before we could route this install.
329
347
  const selfPending = selfLoading === true
330
348
  return (
331
- <div className="px-7 py-4 bg-theme-base border-t border-theme-border">
349
+ <div className="shrink-0 px-8 py-5 bg-theme-base border-t border-theme-border">
332
350
  {self && self.ownership !== 'unknown' && (
333
- <div className="mb-3 card-inner text-[11.5px] leading-snug text-theme-text-secondary">
351
+ <div className="mb-3.5 card-inner p-3 text-[12px] leading-relaxed text-theme-text-secondary">
334
352
  {ambiguous ? (
335
353
  <>
336
354
  Radar found conflicting management metadata on this install, so it can't say whether a Helm
@@ -367,14 +385,20 @@ function ModalFooter({
367
385
  </div>
368
386
  )}
369
387
  {notice && (
370
- <div className="mb-3 card-inner text-[11.5px] leading-snug text-theme-text-secondary">{notice}</div>
388
+ <div className="mb-3.5 card-inner p-3 text-[12px] leading-relaxed text-theme-text-secondary">{notice}</div>
389
+ )}
390
+ {lane === 'driver' && (
391
+ <p className="mb-3.5 text-[12px] leading-relaxed text-theme-text-secondary">
392
+ The guided setup runs right here in the app — Radar installs the Cloud agent on this cluster, and
393
+ you approve the connection in your browser.
394
+ </p>
371
395
  )}
372
- <div className="flex items-center gap-4">
396
+ <div className="flex flex-wrap items-center gap-x-5 gap-y-2.5">
373
397
  {lane === 'driver' ? (
374
398
  <>
375
399
  <button
376
400
  onClick={onConnect}
377
- className="px-5 py-2 rounded-[10px] bg-emerald-500 hover:bg-emerald-400 text-emerald-950 text-[13.5px] font-bold shadow-[0_0_22px_rgba(16,185,129,0.35)] hover:shadow-[0_0_30px_rgba(16,185,129,0.5)] hover:-translate-y-px transition-all"
401
+ className="whitespace-nowrap px-6 py-2.5 rounded-[10px] bg-emerald-500 hover:bg-emerald-400 text-emerald-950 text-[14px] font-bold shadow-[0_0_22px_rgba(16,185,129,0.35)] hover:shadow-[0_0_30px_rgba(16,185,129,0.5)] hover:-translate-y-px transition-all"
378
402
  >
379
403
  Connect this cluster
380
404
  </button>
@@ -382,9 +406,9 @@ function ModalFooter({
382
406
  href={driverEscapeUrl}
383
407
  target="_blank"
384
408
  rel="noopener noreferrer"
385
- className="text-[12.5px] text-theme-text-secondary hover:text-theme-text-primary underline underline-offset-2 transition-colors"
409
+ className="whitespace-nowrap text-[13px] text-theme-text-secondary hover:text-theme-text-primary underline underline-offset-2 transition-colors"
386
410
  >
387
- or start in the browser
411
+ or set up in the browser
388
412
  </a>
389
413
  </>
390
414
  ) : cliOnly ? null : (
@@ -399,33 +423,32 @@ function ModalFooter({
399
423
  {self?.ownership === 'helm' || gitops ? 'Connect this cluster' : 'Try Cloud free'}
400
424
  </a>
401
425
  )}
402
- <button onClick={onLater} className="text-[12.5px] text-theme-text-tertiary hover:text-theme-text-primary transition-colors">
426
+ <button onClick={onLater} className="whitespace-nowrap text-[13px] text-theme-text-tertiary hover:text-theme-text-primary transition-colors">
403
427
  Maybe later
404
428
  </button>
405
429
  </div>
406
- <div className="mt-2.5 flex flex-wrap items-center gap-x-4 gap-y-1 text-[11px] text-theme-text-tertiary">
407
- {(assurances?.length ? assurances : DEFAULT_ASSURANCES).map((item) => (
408
- <span key={item} className="flex items-center gap-1">
409
- <Check className="w-3 h-3 text-emerald-600 dark:text-emerald-400" />
430
+ <div className="mt-4 grid grid-cols-2 gap-x-5 gap-y-2 text-[11.5px] text-theme-text-tertiary">
431
+ {assuranceItems(assurances).map((item) => (
432
+ <span key={item} className="flex items-start gap-1.5">
433
+ <Check className="w-3 h-3 mt-[3px] shrink-0 text-emerald-600 dark:text-emerald-400" />
410
434
  {item}
411
435
  </span>
412
436
  ))}
413
437
  </div>
414
- <p className="mt-3.5 text-[11px] text-theme-text-tertiary">
415
- Prefer to run the control plane in your own VPC? Self-hosting is self-serve — 30-day trial, no sales
416
- call.{' '}
438
+ <Fold summary="Prefer to run the control plane in your own VPC?" className="mt-3.5">
439
+ Self-hosting is fully self-serve — set it up yourself, whenever you're ready.{' '}
417
440
  <a href={SELF_HOSTED_DOCS_URL} target="_blank" rel="noopener noreferrer" className="text-theme-text-secondary underline underline-offset-2 hover:text-theme-text-primary">
418
441
  Read the docs
419
442
  </a>
420
443
  .
421
- </p>
444
+ </Fold>
422
445
  </div>
423
446
  )
424
447
  }
425
448
 
426
449
  // Headline defuses the paywall fear before anything is pitched; the grid
427
- // carries the concrete capabilities; the humans strip closes with the
428
- // anti-sell — the credibility beat that makes the sell land.
450
+ // carries the concrete capabilities; the anti-sell closes the credibility
451
+ // beat that makes the sell land.
429
452
  function ModalBody() {
430
453
  const features = [
431
454
  {
@@ -456,43 +479,43 @@ function ModalBody() {
456
479
  },
457
480
  ]
458
481
  return (
459
- <div className="px-7 pt-6 pb-1">
482
+ <div className="px-8 pt-7 pb-2">
460
483
  <Eyebrow />
461
- <h3 className="text-[21px] font-semibold leading-tight tracking-tight text-theme-text-primary mb-2.5 text-balance">
484
+ <h3 className="text-[22px] font-semibold leading-tight tracking-tight text-theme-text-primary mb-3 text-balance">
462
485
  First things first: Radar stays free.
463
486
  </h3>
464
- <p className="text-[13.5px] leading-relaxed text-theme-text-secondary mb-4">
487
+ <p className="text-[14px] leading-relaxed text-theme-text-secondary mb-5">
465
488
  The app you're looking at is Apache&nbsp;2.0 — every feature, forever, no rug pulls.{' '}
466
489
  <b className="text-theme-text-primary font-semibold">Radar Cloud is how we keep the lights on:</b> the
467
490
  same Radar, plus the parts that are genuinely hard to run on your own.
468
491
  </p>
469
- <div className="grid grid-cols-2 gap-2 mb-4">
492
+ <div className="grid grid-cols-2 gap-3 mb-5">
470
493
  {features.map(({ icon: Icon, title, body, wide }) => (
471
- <div key={title} className={`card-inner-lg flex gap-2.5 ${wide ? 'col-span-2' : ''}`}>
494
+ <div
495
+ key={title}
496
+ className={`card-inner-lg p-3.5 flex gap-3 ${
497
+ wide ? 'col-span-2 bg-emerald-500/[0.06] border-emerald-500/25 dark:bg-emerald-500/[0.08]' : ''
498
+ }`}
499
+ >
472
500
  <Icon className="w-4 h-4 shrink-0 mt-0.5 text-emerald-600 dark:text-emerald-400" />
473
501
  <div>
474
- <div className="text-[12.5px] font-semibold text-theme-text-primary">{title}</div>
475
- <p className="mt-0.5 text-[11.5px] leading-snug text-theme-text-tertiary">{body}</p>
502
+ <div className="text-[13px] font-semibold text-theme-text-primary">{title}</div>
503
+ <p className="mt-1 text-[12px] leading-relaxed text-theme-text-tertiary">{body}</p>
476
504
  </div>
477
505
  </div>
478
506
  ))}
479
507
  </div>
480
- <div className="flex items-start gap-2.5 mb-4">
481
- <div className="mt-0.5">
482
- <Faces />
483
- </div>
484
- <div className="min-w-0">
485
- <p className="text-[12px] leading-snug text-theme-text-secondary">
486
- If it's just you and one cluster — honestly, stay right here. This app is the product, not a demo.
487
- </p>
488
- <p className="mt-1 text-[11px] leading-snug text-theme-text-tertiary">
489
- Radar is built in the open by many hands, and overseen by a small team of humans — the kind you can
490
- actually talk to.{' '}
491
- <a href={ABOUT_URL} target="_blank" rel="noopener noreferrer" className="whitespace-nowrap text-theme-text-secondary underline underline-offset-2 hover:text-theme-text-primary">
492
- Meet us →
493
- </a>
494
- </p>
495
- </div>
508
+ <div className="mb-5 border-l-2 border-emerald-500/40 pl-3.5">
509
+ <p className="text-[12.5px] leading-relaxed text-theme-text-secondary">
510
+ If it's just you and one cluster — honestly, stay right here. This app is the product, not a demo.
511
+ </p>
512
+ <p className="mt-2 text-[11.5px] leading-relaxed text-theme-text-tertiary">
513
+ Radar is built in the open by many hands, and overseen by a small team of humans — the kind you
514
+ can actually talk to.{' '}
515
+ <a href={ABOUT_URL} target="_blank" rel="noopener noreferrer" className="whitespace-nowrap text-theme-text-secondary underline underline-offset-2 hover:text-theme-text-primary">
516
+ Meet us
517
+ </a>
518
+ </p>
496
519
  </div>
497
520
  </div>
498
521
  )
@@ -32,6 +32,14 @@ export interface Target {
32
32
  kind: string;
33
33
  namespace: string;
34
34
  name: string;
35
+ /** The issue this investigation is for, when it came from an issue. Hosts
36
+ * that group sessions by issue key on it; the rest carry it and ignore it. */
37
+ issueId?: string;
38
+ /** Start a new session instead of continuing one the backend would otherwise
39
+ * return for this target. Rides here rather than as a separate argument so
40
+ * the consent-deferred path replays one object — a parallel "was it fresh?"
41
+ * state is a thing that can fall out of sync with the target it describes. */
42
+ fresh?: boolean;
35
43
  }
36
44
  export type DiagnoseView = "home" | "investigation";
37
45
 
@@ -66,6 +74,10 @@ interface DiagnoseCtx {
66
74
  historyDegraded: boolean; // persistence broke — history won't survive a restart
67
75
  needsConsent: boolean; // a start is pending the one-time consent
68
76
  startError: string | null;
77
+ // Kept apart from startError: the consent card renders this as "why your
78
+ // approval was refused", and a run-start failure landing in the same slot
79
+ // would be read as exactly that — the two paths don't share a lifecycle.
80
+ consentError: string | null;
69
81
  openInvestigation: (t: Target) => void;
70
82
  openRun: (id: string) => void;
71
83
  openHome: () => void;
@@ -200,6 +212,7 @@ export function DiagnoseProvider({ children }: { children: ReactNode }) {
200
212
  const [historyDegraded, setHistoryDegraded] = useState(false);
201
213
  const [pendingTarget, setPendingTarget] = useState<Target | null>(null);
202
214
  const [startError, setStartError] = useState<string | null>(null);
215
+ const [consentError, setConsentError] = useState<string | null>(null);
203
216
  const [width, setWidth] = useState<number>(() => {
204
217
  try {
205
218
  const v = Number(localStorage.getItem(WIDTH_KEY));
@@ -341,7 +354,7 @@ export function DiagnoseProvider({ children }: { children: ReactNode }) {
341
354
  // panel closed — and only re-render when the set actually changes, not every poll.
342
355
  const runningSig = runs
343
356
  .filter((r) => r.status === "running")
344
- .map((r) => `${r.kind}\x00${r.namespace}\x00${r.name}`)
357
+ .map((r) => runTargetKey(r.kind, r.namespace, r.name))
345
358
  .sort()
346
359
  .join("|");
347
360
  const runningKeys = useMemo(
@@ -398,6 +411,7 @@ export function DiagnoseProvider({ children }: { children: ReactNode }) {
398
411
  const openInvestigation = useCallback(
399
412
  (t: Target) => {
400
413
  setStartError(null);
414
+ setConsentError(null);
401
415
  setOpen(true);
402
416
  if (!hosted && !consentSurface) {
403
417
  setPendingTarget(null);
@@ -449,21 +463,29 @@ export function DiagnoseProvider({ children }: { children: ReactNode }) {
449
463
  }
450
464
  if (id) openRun(id);
451
465
  }, [available, openRun]);
466
+ // Leaving the detail pane drops the failure that belonged to it. startError
467
+ // renders as the entire pane (maximized home still shows `detail`), where a
468
+ // message about a resource you just navigated away from has nothing to attach
469
+ // to and no way to be dismissed.
452
470
  const openHome = useCallback(() => {
453
471
  setView("home");
472
+ setStartError(null);
454
473
  setOpen(true);
455
474
  }, []);
456
- const goHome = useCallback(() => setView("home"), []);
475
+ const goHome = useCallback(() => {
476
+ setView("home");
477
+ setStartError(null);
478
+ }, []);
457
479
  const close = useCallback(() => setOpen(false), []);
458
480
  const consentBusyRef = useRef(false);
459
481
  const approveConsent = useCallback(() => {
460
482
  if (consentBusyRef.current) return;
461
483
  if (!consentSurface) {
462
- setStartError("Radar can’t record consent for this agent.");
484
+ setConsentError("Radar can’t record consent for this agent.");
463
485
  return;
464
486
  }
465
487
  consentBusyRef.current = true;
466
- setStartError(null);
488
+ setConsentError(null);
467
489
  const t = pendingTarget;
468
490
  // The server ENFORCES consent at start, so the acknowledgment must land
469
491
  // before the run request — awaiting also makes it durable for the CLI.
@@ -475,8 +497,16 @@ export function DiagnoseProvider({ children }: { children: ReactNode }) {
475
497
  setPendingTarget(null);
476
498
  if (t) startRunRef.current(t);
477
499
  })
478
- .catch(() => {
479
- setStartError("Couldn't record your consent try again.");
500
+ .catch((e) => {
501
+ // The server's message, when it has one. A host that records consent
502
+ // above the individual refuses whoever isn't allowed to grant it, and
503
+ // only its message can say who is — "try again" sends them at something
504
+ // that can never succeed.
505
+ setConsentError(
506
+ e instanceof DiagnoseError && e.message
507
+ ? e.message
508
+ : "Couldn't record your consent — try again.",
509
+ );
480
510
  })
481
511
  .finally(() => {
482
512
  consentBusyRef.current = false;
@@ -484,6 +514,7 @@ export function DiagnoseProvider({ children }: { children: ReactNode }) {
484
514
  }, [consentSurface, pendingTarget]);
485
515
  const cancelConsent = useCallback(() => {
486
516
  setPendingTarget(null);
517
+ setConsentError(null);
487
518
  setOpen(false);
488
519
  }, []);
489
520
  const dismissError = useCallback(() => setStartError(null), []);
@@ -517,6 +548,7 @@ export function DiagnoseProvider({ children }: { children: ReactNode }) {
517
548
  // cleared on approve/cancel — so its presence is exactly "consent needed now".
518
549
  needsConsent: !!pendingTarget,
519
550
  startError,
551
+ consentError,
520
552
  openInvestigation,
521
553
  openRun,
522
554
  openHome,
@@ -0,0 +1,70 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { canStartNewInvestigation } from "./DiagnoseSurface";
3
+ import type { RunSummary } from "../../api/diagnose";
4
+
5
+ // The "new investigation" button dispatches an agent and spends the user's own
6
+ // tokens, so every one of these clauses is load-bearing rather than cosmetic.
7
+ // Each case below is a way it misfired before the gate existed.
8
+ function run(status: RunSummary["status"]): RunSummary {
9
+ return {
10
+ id: "r1",
11
+ kind: "Deployment",
12
+ namespace: "prod",
13
+ name: "payments",
14
+ context: "prod-cluster",
15
+ status,
16
+ createdAt: "2026-01-01T00:00:00Z",
17
+ updatedAt: "2026-01-01T00:00:00Z",
18
+ };
19
+ }
20
+
21
+ describe("canStartNewInvestigation", () => {
22
+ it("offers a new investigation on a finished run", () => {
23
+ expect(canStartNewInvestigation("investigation", run("done"), false)).toBe(
24
+ true,
25
+ );
26
+ });
27
+
28
+ it("stays hidden on the investigations list", () => {
29
+ // goHome() leaves activeRunId set, so the header still has a run to read.
30
+ // Without the view check the click starts an agent on a resource the user
31
+ // navigated away from, over a list of unrelated investigations.
32
+ expect(canStartNewInvestigation("home", run("done"), false)).toBe(false);
33
+ });
34
+
35
+ it("stays hidden while a turn is in flight", () => {
36
+ // A start would be handed back the live run, so the button does nothing.
37
+ expect(canStartNewInvestigation("investigation", run("running"), false)).toBe(
38
+ false,
39
+ );
40
+ });
41
+
42
+ it("stays hidden on a stale run", () => {
43
+ // The body offers "Re-run on current cluster" WITH the context-changed
44
+ // warning. A bare + carries none of it, at a resource that may not exist in
45
+ // the context it would now run against.
46
+ expect(canStartNewInvestigation("investigation", run("stale"), false)).toBe(
47
+ false,
48
+ );
49
+ });
50
+
51
+ it("stays hidden while consent is pending", () => {
52
+ expect(canStartNewInvestigation("investigation", run("done"), true)).toBe(
53
+ false,
54
+ );
55
+ });
56
+
57
+ it("stays hidden with no focused run", () => {
58
+ expect(canStartNewInvestigation("investigation", null, false)).toBe(false);
59
+ });
60
+
61
+ it("offers one on an errored or stopped run", () => {
62
+ // Those are the runs a person most wants to start over from.
63
+ expect(canStartNewInvestigation("investigation", run("error"), false)).toBe(
64
+ true,
65
+ );
66
+ expect(canStartNewInvestigation("investigation", run("stopped"), false)).toBe(
67
+ true,
68
+ );
69
+ });
70
+ });
@@ -15,6 +15,7 @@ import {
15
15
  TerminalSquare,
16
16
  Copy,
17
17
  Check,
18
+ Plus,
18
19
  } from "lucide-react";
19
20
  import { Tooltip } from "../ui/Tooltip";
20
21
  import {
@@ -22,6 +23,7 @@ import {
22
23
  useDiagnoseLayout,
23
24
  agentLabelFor,
24
25
  openDiagnoseSettings,
26
+ type DiagnoseView,
25
27
  } from "./DiagnoseContext";
26
28
  import { useDiagnoseCustomization } from "../../context/DiagnoseCustomization";
27
29
  import { InvestigationView } from "./InvestigationView";
@@ -141,6 +143,33 @@ function InvestigationMenu({ run }: { run: RunSummary }) {
141
143
  // header, right of the nav rail) — App renders it there and passes topInset (the
142
144
  // header height; 0 in chromeless embeds). It shares that frame with the resource/
143
145
  // Helm drawers, so it no longer floats viewport-fixed or DOM-measures the chrome.
146
+ // Whether the header offers a new investigation on the focused run's resource.
147
+ // Every clause is a failure this button actually had:
148
+ //
149
+ // view goHome() leaves activeRunId set, so the header keeps rendering
150
+ // the last focused run. Without this the button dispatches an
151
+ // agent — real tokens — from a screen showing an unrelated list.
152
+ // run nothing to take a resource from.
153
+ // running a start is handed back the live run, so the click does nothing
154
+ // and the button reads as broken.
155
+ // stale the body already offers "Re-run on current cluster" WITH the
156
+ // warning that the context changed; a bare + carries none of it,
157
+ // and the resource may not exist in the context it'd run against.
158
+ // needsConsent the consent card owns the surface until it's answered.
159
+ export function canStartNewInvestigation(
160
+ view: DiagnoseView,
161
+ run: RunSummary | null,
162
+ needsConsent: boolean,
163
+ ): boolean {
164
+ return (
165
+ view === "investigation" &&
166
+ !!run &&
167
+ run.status !== "running" &&
168
+ run.status !== "stale" &&
169
+ !needsConsent
170
+ );
171
+ }
172
+
144
173
  export function DiagnoseSurface({ topInset = 0 }: { topInset?: number }) {
145
174
  const d = useDiagnose();
146
175
  // Injected settings action: undefined = Radar's own Settings dialog;
@@ -223,6 +252,7 @@ export function DiagnoseSurface({ topInset = 0 }: { topInset?: number }) {
223
252
  profile={d.profile}
224
253
  copy={consentCopy}
225
254
  onOpenSettings={openSettings ?? undefined}
255
+ error={d.consentError}
226
256
  onApprove={d.approveConsent}
227
257
  onCancel={d.cancelConsent}
228
258
  />
@@ -341,6 +371,26 @@ export function DiagnoseSurface({ topInset = 0 }: { topInset?: number }) {
341
371
  </div>
342
372
  </div>
343
373
  <div className="flex shrink-0 items-center gap-0.5">
374
+ {activeRun &&
375
+ canStartNewInvestigation(d.view, activeRun, d.needsConsent) && (
376
+ <Tooltip content="New investigation on this resource" position="bottom">
377
+ <button
378
+ onClick={() =>
379
+ d.openInvestigation({
380
+ kind: activeRun.kind,
381
+ namespace: activeRun.namespace,
382
+ name: activeRun.name,
383
+ issueId: activeRun.issueId,
384
+ fresh: true,
385
+ })
386
+ }
387
+ className="rounded-md p-1 text-theme-text-tertiary hover:bg-theme-hover hover:text-theme-text-primary"
388
+ aria-label="New investigation on this resource"
389
+ >
390
+ <Plus className="h-4 w-4" />
391
+ </button>
392
+ </Tooltip>
393
+ )}
344
394
  {activeRun && <InvestigationMenu run={activeRun} />}
345
395
  <Tooltip content={maximized ? "Restore" : "Expand"} position="bottom">
346
396
  <button
@@ -46,10 +46,21 @@ export function InvestigationView({
46
46
  const { kind, namespace, name } = run;
47
47
  // Apply is off for hosted agents (read-only server-side). Keyed on the selected
48
48
  // agent, which matches run.agent unless a deployment mixes hosted + local agents.
49
- const { refreshRuns, openInvestigation, startError, hosted } = useDiagnose();
49
+ const { refreshRuns, openInvestigation, startError, dismissError, hosted } =
50
+ useDiagnose();
51
+ // Re-run means look again, so it asks for a new session explicitly and only
52
+ // carries the issue forward — being handed the previous answer is the one
53
+ // thing someone clicking this doesn't want.
50
54
  const retryDiagnosis = useCallback(
51
- () => openInvestigation({ kind, namespace, name }),
52
- [openInvestigation, kind, namespace, name],
55
+ () =>
56
+ openInvestigation({
57
+ kind,
58
+ namespace,
59
+ name,
60
+ issueId: run.issueId,
61
+ fresh: true,
62
+ }),
63
+ [openInvestigation, kind, namespace, name, run.issueId],
53
64
  );
54
65
  const queryClient = useQueryClient();
55
66
  const [turns, setTurns] = useState<Turn[]>([]);
@@ -470,7 +481,7 @@ export function InvestigationView({
470
481
  </span>
471
482
  </div>
472
483
  <button
473
- onClick={() => openInvestigation({ kind, namespace, name })}
484
+ onClick={retryDiagnosis}
474
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"
475
486
  >
476
487
  <Send className="h-3 w-3" />
@@ -489,7 +500,7 @@ export function InvestigationView({
489
500
  </span>
490
501
  </div>
491
502
  <button
492
- onClick={() => openInvestigation({ kind, namespace, name })}
503
+ onClick={retryDiagnosis}
493
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"
494
505
  >
495
506
  <Send className="h-3 w-3" />
@@ -525,10 +536,36 @@ export function InvestigationView({
525
536
  />
526
537
  );
527
538
  })}
528
- {(actionError || startError) && (
539
+ {actionError && (
529
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">
530
541
  <AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-red-400" />
531
- <span>{actionError || startError}</span>
542
+ <span>{actionError}</span>
543
+ </div>
544
+ )}
545
+ {/* A start that failed belongs to the investigation that never began,
546
+ not to the one on screen. Unlabelled at the foot of a finished
547
+ transcript — verdict directly above — it reads as "this
548
+ investigation failed". It also outlives the click that caused it,
549
+ and this is the only place it surfaces while a run is focused, so
550
+ 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
560
+ </div>
561
+ <div className="text-theme-text-secondary">{startError}</div>
562
+ </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>
532
569
  </div>
533
570
  )}
534
571
  </div>
@@ -123,3 +123,42 @@ describe("ConsentCard execution profile treatment", () => {
123
123
  expect(html).not.toContain("Radar still enables the agent CLI");
124
124
  });
125
125
  });
126
+
127
+ // The consent card's error box is the ONLY place a refused approval is
128
+ // explained: the card stays mounted on failure, focus doesn't move, and nothing
129
+ // else on screen changes. Red (not the card's own amber) and role="alert", or a
130
+ // screen-reader user re-presses a button the server will never accept.
131
+ describe("ConsentCard error", () => {
132
+ const base = {
133
+ agentName: "Claude",
134
+ profile: "safeguarded" as ExecutionProfile,
135
+ onApprove: noop,
136
+ onCancel: noop,
137
+ };
138
+
139
+ it("renders the server's refusal as an alert", () => {
140
+ const html = renderToStaticMarkup(
141
+ <ConsentCard {...base} error="An owner has to allow it once." />,
142
+ );
143
+ expect(html).toContain('role="alert"');
144
+ expect(html).toContain("An owner has to allow it once.");
145
+ });
146
+
147
+ it("uses the error tone, not the card's warning tone", () => {
148
+ // full-local renders the card itself amber; an amber box on it reads as
149
+ // another paragraph of body copy rather than a failure.
150
+ const html = renderToStaticMarkup(
151
+ <ConsentCard
152
+ {...base}
153
+ profile={"full-local" as ExecutionProfile}
154
+ error="Refused."
155
+ />,
156
+ );
157
+ expect(html).toContain("border-red-500/30");
158
+ });
159
+
160
+ it("renders no alert when the approval hasn't failed", () => {
161
+ const html = renderToStaticMarkup(<ConsentCard {...base} />);
162
+ expect(html).not.toContain('role="alert"');
163
+ });
164
+ });