@skyhook-io/radar-app 1.14.2 → 1.14.3

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 (74) hide show
  1. package/package.json +2 -2
  2. package/src/api/client.ts +30 -0
  3. package/src/components/CloudConnectFlow.tsx +21 -7
  4. package/src/components/CloudFunnelButton.tsx +85 -8
  5. package/src/components/cloudConnectHandoff.test.ts +10 -1
  6. package/src/components/cloudConnectHandoff.ts +15 -1
  7. package/src/components/diagnose/AIMarkdown.tsx +87 -0
  8. package/src/components/diagnose/ActivityTurn.tsx +1244 -0
  9. package/src/components/diagnose/AgentControls.tsx +603 -0
  10. package/src/components/diagnose/AnalysisStory.test.tsx +31 -25
  11. package/src/components/diagnose/AnalysisStory.tsx +13 -18
  12. package/src/components/diagnose/ApplyDialog.tsx +289 -0
  13. package/src/components/diagnose/AssessmentCard.tsx +1560 -0
  14. package/src/components/diagnose/DiagnoseSurface.test.tsx +9 -9
  15. package/src/components/diagnose/DiagnoseSurface.tsx +5 -1
  16. package/src/components/diagnose/EvidenceCard.tsx +611 -0
  17. package/src/components/diagnose/Home.test.tsx +1 -3
  18. package/src/components/diagnose/Home.tsx +2 -1
  19. package/src/components/diagnose/InvestigationEvidencePane.story.test.tsx +6 -8
  20. package/src/components/diagnose/InvestigationEvidencePane.test.tsx +5 -3
  21. package/src/components/diagnose/InvestigationEvidencePane.tsx +31 -3445
  22. package/src/components/diagnose/InvestigationEvidenceSections.tsx +523 -0
  23. package/src/components/diagnose/InvestigationView.tsx +11 -28
  24. package/src/components/diagnose/StoryExcerpt.tsx +118 -0
  25. package/src/components/diagnose/assessmentCopy.ts +169 -0
  26. package/src/components/diagnose/investigationCase.test.tsx +631 -2
  27. package/src/components/diagnose/investigationCase.ts +132 -24
  28. package/src/components/diagnose/investigationEvidence/adapters/coverage.test.tsx +660 -0
  29. package/src/components/diagnose/investigationEvidence/adapters/{helm.test.ts → helm.test.tsx} +63 -0
  30. package/src/components/diagnose/investigationEvidence/adapters/helm.ts +70 -1
  31. package/src/components/diagnose/investigationEvidence/adapters/listings.ts +134 -0
  32. package/src/components/diagnose/investigationEvidence/adapters/{permissions.test.ts → permissions.test.tsx} +50 -0
  33. package/src/components/diagnose/investigationEvidence/adapters/permissions.ts +18 -0
  34. package/src/components/diagnose/investigationEvidence/adapters/posture.ts +272 -0
  35. package/src/components/diagnose/investigationEvidence/adapters/prometheus.ts +49 -0
  36. package/src/components/diagnose/investigationEvidence/adapters/rankings.ts +147 -0
  37. package/src/components/diagnose/investigationEvidence/adapters/resource.ts +20 -10
  38. package/src/components/diagnose/investigationEvidence/bodies/index.tsx +127 -0
  39. package/src/components/diagnose/investigationEvidence/bodies/inventory.tsx +158 -0
  40. package/src/components/diagnose/investigationEvidence/bodies/metrics.tsx +373 -0
  41. package/src/components/diagnose/investigationEvidence/bodies/network.tsx +274 -0
  42. package/src/components/diagnose/investigationEvidence/bodies/platform.tsx +475 -0
  43. package/src/components/diagnose/investigationEvidence/bodies/posture.tsx +60 -0
  44. package/src/components/diagnose/investigationEvidence/bodies/ranking.tsx +76 -0
  45. package/src/components/diagnose/investigationEvidence/bodies/streams.tsx +212 -0
  46. package/src/components/diagnose/investigationEvidence/bodies/workload.tsx +335 -0
  47. package/src/components/diagnose/investigationEvidence/builder.ts +15 -0
  48. package/src/components/diagnose/investigationEvidence/cardParts.tsx +312 -0
  49. package/src/components/diagnose/investigationEvidence/excerpt.test.ts +105 -0
  50. package/src/components/diagnose/investigationEvidence/excerpt.ts +92 -0
  51. package/src/components/diagnose/investigationEvidence/identity.test.ts +5 -7
  52. package/src/components/diagnose/investigationEvidence/index.ts +1 -1
  53. package/src/components/diagnose/investigationEvidence/navigation.tsx +26 -0
  54. package/src/components/diagnose/investigationEvidence/observations.ts +11 -0
  55. package/src/components/diagnose/investigationEvidence/projection.test.ts +1 -1
  56. package/src/components/diagnose/investigationEvidence/types.ts +97 -2
  57. package/src/components/diagnose/investigationEvidenceKinds.ts +22 -0
  58. package/src/components/diagnose/investigationEvidencePartition.ts +296 -0
  59. package/src/components/diagnose/parts.test.tsx +3 -1
  60. package/src/components/diagnose/parts.tsx +26 -3904
  61. package/src/components/diagnose/target.ts +9 -3
  62. package/src/components/diagnose/toolCallLabel.test.ts +47 -1
  63. package/src/components/diagnose/toolCallLabel.ts +41 -2
  64. package/src/components/helm/HelmReleaseDrawer.tsx +1 -1
  65. package/src/components/home/MCPSetupDialog.tsx +1 -1
  66. package/src/components/resource/PrometheusChartsGrid.tsx +1 -1
  67. package/src/components/resource/WorkloadMetricsHelpDialog.tsx +1 -1
  68. package/src/components/resource/WorkloadMetricsSection.tsx +1 -1
  69. package/src/components/resources/PodFilePreview.tsx +1 -1
  70. package/src/components/ui/CommandPalette.tsx +12 -8
  71. package/src/components/ui/DiagnosticsOverlay.tsx +7 -5
  72. package/src/components/ui/Omnibar.tsx +33 -10
  73. package/src/components/ui/ShortcutHelpOverlay.tsx +8 -5
  74. package/src/components/ui/Disclosure.tsx +0 -47
@@ -1,6 +1,9 @@
1
1
  import { resourceKey } from "@skyhook-io/k8s-ui";
2
2
 
3
- import { kindToPluralWithGroup, knownKindForPluralWithGroup } from "../../utils/navigation";
3
+ import {
4
+ kindToPluralWithGroup,
5
+ knownKindForPluralWithGroup,
6
+ } from "../../utils/navigation";
4
7
 
5
8
  export interface InvestigationTargetIdentity {
6
9
  kind: string;
@@ -31,7 +34,10 @@ export function runTargetKey(
31
34
  export function formatInvestigationTarget(
32
35
  target: InvestigationTargetIdentity,
33
36
  ): string {
34
- const canonicalKind = knownKindForPluralWithGroup(target.kind, target.group) ?? target.kind;
35
- const kind = target.group ? `${canonicalKind}.${target.group}` : canonicalKind;
37
+ const canonicalKind =
38
+ knownKindForPluralWithGroup(target.kind, target.group) ?? target.kind;
39
+ const kind = target.group
40
+ ? `${canonicalKind}.${target.group}`
41
+ : canonicalKind;
36
42
  return `${kind} ${target.namespace ? `${target.namespace}/` : ""}${target.name}`;
37
43
  }
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { describeToolCall } from "./toolCallLabel";
2
+ import { describeToolCall, investigationRunningLabel } from "./toolCallLabel";
3
3
 
4
4
  describe("describeToolCall", () => {
5
5
  it("never invents a singular from a kind", () => {
@@ -49,3 +49,49 @@ describe("describeToolCall", () => {
49
49
  expect(describeToolCall("some_new_tool", undefined)).toBe("Some New Tool");
50
50
  });
51
51
  });
52
+
53
+ describe("investigationRunningLabel", () => {
54
+ const read = {
55
+ tool: "get_resource",
56
+ status: "done",
57
+ summary: JSON.stringify({
58
+ kind: "deployment",
59
+ namespace: "shop",
60
+ name: "api",
61
+ }),
62
+ };
63
+ it("names the call in flight, keeps a finished one briefly, then thinks", () => {
64
+ expect(
65
+ investigationRunningLabel({
66
+ lastTool: { ...read, status: "running" },
67
+ reads: 1,
68
+ quietFor: 20000,
69
+ elapsedSeconds: 9,
70
+ }),
71
+ ).toEqual({
72
+ current: "Reading Deployment shop/api…",
73
+ meta: "1 read · 9 s",
74
+ });
75
+ expect(
76
+ investigationRunningLabel({
77
+ lastTool: read,
78
+ reads: 3,
79
+ quietFor: 7999,
80
+ elapsedSeconds: 30,
81
+ }).current,
82
+ ).toBe("Reading Deployment shop/api…");
83
+ expect(
84
+ investigationRunningLabel({
85
+ lastTool: read,
86
+ reads: 3,
87
+ quietFor: 8000,
88
+ elapsedSeconds: 30,
89
+ }),
90
+ ).toEqual({ current: "Thinking…", meta: "3 reads · 30 s" });
91
+ });
92
+ it("says Investigating with nothing read yet and omits a timer under two seconds", () => {
93
+ expect(
94
+ investigationRunningLabel({ reads: 0, quietFor: 0, elapsedSeconds: 1 }),
95
+ ).toEqual({ current: "Investigating…", meta: "" });
96
+ });
97
+ });
@@ -1,5 +1,3 @@
1
- import { prettyTool } from "./parts";
2
-
3
1
  // One readable sentence for a tool call in flight, from the call's arguments:
4
2
  // "Reading logs for pod shop/api-7d4 / api" tells the reader what the agent
5
3
  // is looking at; "Get Pod Logs" tells them which tool ran.
@@ -133,3 +131,44 @@ function pluralLabel(kind: string): string {
133
131
  const base = kindLabel(kind) ?? kind;
134
132
  return base.endsWith("s") ? base : `${base}s`;
135
133
  }
134
+
135
+ export function prettyTool(tool: string): string {
136
+ return tool.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
137
+ }
138
+
139
+ const THINKING_AFTER_MS = 8000;
140
+
141
+ /**
142
+ * The Assessment card's running row: the call in flight or just finished on
143
+ * the left, the read count and the timer on the right. A finished call keeps
144
+ * its name for a few quiet seconds, then the label admits the agent is
145
+ * thinking rather than showing a stale read.
146
+ */
147
+ export function investigationRunningLabel({
148
+ lastTool,
149
+ reads,
150
+ quietFor,
151
+ elapsedSeconds,
152
+ }: {
153
+ lastTool?: { tool?: string; status?: string; summary?: string };
154
+ reads: number;
155
+ quietFor: number;
156
+ elapsedSeconds: number;
157
+ }): { current: string; meta: string } {
158
+ let current = "Investigating";
159
+ if (lastTool?.tool) {
160
+ current =
161
+ lastTool.status === "running" || quietFor < THINKING_AFTER_MS
162
+ ? describeToolCall(lastTool.tool, lastTool.summary)
163
+ : "Thinking";
164
+ }
165
+ return {
166
+ current: `${current}…`,
167
+ meta: [
168
+ reads > 0 ? `${reads} ${reads === 1 ? "read" : "reads"}` : undefined,
169
+ elapsedSeconds >= 2 ? `${elapsedSeconds} s` : undefined,
170
+ ]
171
+ .filter(Boolean)
172
+ .join(" · "),
173
+ };
174
+ }
@@ -26,7 +26,7 @@ import { ValuesViewer } from './ValuesViewer'
26
26
  import { OwnedResources } from './OwnedResources'
27
27
  import { TrackChartSourceDialog } from './TrackChartSourceDialog'
28
28
  import { Collapse, CollapseChevron } from '@skyhook-io/k8s-ui/components/ui/Collapse'
29
- import { Disclosure } from '../ui/Disclosure'
29
+ import { Disclosure } from '@skyhook-io/k8s-ui/components/ui/Disclosure'
30
30
 
31
31
  interface HelmReleaseDrawerProps {
32
32
  release: SelectedHelmRelease
@@ -3,7 +3,7 @@ import { X, Copy, Check, Radio, Terminal, MessageSquare, Code2, Pin } from 'luci
3
3
  import { apiUrl, getAuthHeaders, getCredentialsMode } from '../../api/config'
4
4
  import { MCP_TOOL_CATALOG } from './mcpToolCatalog'
5
5
  import { Tooltip } from '../ui/Tooltip'
6
- import { Disclosure } from '../ui/Disclosure'
6
+ import { Disclosure } from '@skyhook-io/k8s-ui/components/ui/Disclosure'
7
7
 
8
8
  interface MCPSetupDialogProps {
9
9
  open: boolean
@@ -1,7 +1,7 @@
1
1
  import { useMemo } from "react";
2
2
  import { useSearchParams } from "react-router-dom";
3
3
  import { Loader2, Wifi, WifiOff } from "lucide-react";
4
- import { Disclosure } from "../ui/Disclosure";
4
+ import { Disclosure } from "@skyhook-io/k8s-ui/components/ui/Disclosure";
5
5
  import {
6
6
  AreaChart,
7
7
  SeriesLegend,
@@ -1,7 +1,7 @@
1
1
  import { useLayoutEffect, useId, useRef } from 'react'
2
2
  import { createPortal } from 'react-dom'
3
3
  import { ExternalLink, X } from 'lucide-react'
4
- import { Disclosure } from '../ui/Disclosure'
4
+ import { Disclosure } from '@skyhook-io/k8s-ui/components/ui/Disclosure'
5
5
  import type { WorkloadMetrics } from '../../api/workloadMetrics'
6
6
  import type { PrometheusTimeRange } from '../../api/client'
7
7
 
@@ -18,7 +18,7 @@ import {
18
18
  } from "../../api/workloadMetrics";
19
19
  import { latestWorkloadValue, workloadPodValues } from "./workloadMetricValues";
20
20
  import { WorkloadMetricsHelpDialog } from './WorkloadMetricsHelpDialog';
21
- import { Disclosure } from '../ui/Disclosure';
21
+ import { Disclosure } from '@skyhook-io/k8s-ui/components/ui/Disclosure';
22
22
 
23
23
  interface Props {
24
24
  kind: string;
@@ -3,7 +3,7 @@ import Editor from '@monaco-editor/react'
3
3
  import { AlertTriangle, Download, FileText, RotateCw } from 'lucide-react'
4
4
  import { PaneLoader, ensureMonacoRuntime } from '@skyhook-io/k8s-ui'
5
5
  import { formatBytes } from '../../utils/format'
6
- import { Disclosure } from '../ui/Disclosure'
6
+ import { Disclosure } from '@skyhook-io/k8s-ui/components/ui/Disclosure'
7
7
  import { apiUrl, getAuthHeaders, getCredentialsMode } from '../../api/config'
8
8
 
9
9
  // A curated inline viewer for text files inside a pod container, rendered in
@@ -23,13 +23,17 @@ export function CommandPalette({ onClose, isOpen = true, ...callbacks }: Command
23
23
  // with the standalone omnibar via useCommandItems so the two never drift.
24
24
  const items = useCommandItems(callbacks)
25
25
 
26
- // Focus input on mount
26
+ // Focus the input once logically open. Not on mount: the presence hook
27
+ // mounts the palette two frames before `isOpen` flips, and the root is
28
+ // `inert` until then, so a mount-time focus would be refused.
27
29
  useEffect(() => {
28
- inputRef.current?.focus()
29
- }, [])
30
+ if (isOpen) inputRef.current?.focus()
31
+ }, [isOpen])
30
32
 
31
- // Close on Escape (capture phase to beat the shortcut system)
33
+ // Close on Escape (capture phase to beat the shortcut system) — only while
34
+ // logically open: the palette stays mounted through its exit.
32
35
  useEffect(() => {
36
+ if (!isOpen) return
33
37
  const handler = (e: KeyboardEvent) => {
34
38
  if (e.key === 'Escape') {
35
39
  e.preventDefault()
@@ -39,7 +43,7 @@ export function CommandPalette({ onClose, isOpen = true, ...callbacks }: Command
39
43
  }
40
44
  document.addEventListener('keydown', handler, true)
41
45
  return () => document.removeEventListener('keydown', handler, true)
42
- }, [onClose])
46
+ }, [isOpen, onClose])
43
47
 
44
48
  // Filter and rank results
45
49
  const filteredItems = useMemo(() => {
@@ -122,16 +126,16 @@ export function CommandPalette({ onClose, isOpen = true, ...callbacks }: Command
122
126
  let flatIndex = 0
123
127
 
124
128
  return (
125
- <div className="fixed inset-0 z-[100] flex items-start justify-center pt-[15vh]">
129
+ <div className="fixed inset-0 z-[100] flex items-start justify-center pt-[15vh]" inert={!isOpen || undefined}>
126
130
  {/* Backdrop */}
127
131
  <div
128
132
  className={clsx(
129
133
  'absolute inset-0 bg-theme-base/60 backdrop-blur-sm',
130
134
  TRANSITION_BACKDROP,
131
- isOpen ? 'opacity-100' : 'opacity-0'
135
+ isOpen ? 'opacity-100' : 'opacity-0 pointer-events-none'
132
136
  )}
133
137
  style={overlayTransitionStyle(isOpen, 'dialog')}
134
- onClick={onClose}
138
+ onClick={isOpen ? onClose : undefined}
135
139
  />
136
140
 
137
141
  {/* Panel */}
@@ -17,8 +17,10 @@ export function DiagnosticsOverlay({ onClose, isOpen = true }: DiagnosticsOverla
17
17
  const [copied, setCopied] = useState<'json' | 'formatted' | null>(null)
18
18
  const [reportOpened, setReportOpened] = useState(false)
19
19
 
20
- // Close on Escape (capture phase)
20
+ // Close on Escape (capture phase) — only while logically open: the overlay
21
+ // stays mounted through its exit.
21
22
  useEffect(() => {
23
+ if (!isOpen) return
22
24
  const handler = (e: KeyboardEvent) => {
23
25
  if (e.key === 'Escape') {
24
26
  e.preventDefault()
@@ -28,7 +30,7 @@ export function DiagnosticsOverlay({ onClose, isOpen = true }: DiagnosticsOverla
28
30
  }
29
31
  document.addEventListener('keydown', handler, true)
30
32
  return () => document.removeEventListener('keydown', handler, true)
31
- }, [onClose])
33
+ }, [isOpen, onClose])
32
34
 
33
35
  const copyToClipboard = useCallback(async (type: 'json' | 'formatted') => {
34
36
  if (!data) return
@@ -64,16 +66,16 @@ export function DiagnosticsOverlay({ onClose, isOpen = true }: DiagnosticsOverla
64
66
  }, [data])
65
67
 
66
68
  return (
67
- <div className="fixed inset-0 z-[100] flex items-start justify-center pt-[8vh]">
69
+ <div className="fixed inset-0 z-[100] flex items-start justify-center pt-[8vh]" inert={!isOpen || undefined}>
68
70
  {/* Backdrop */}
69
71
  <div
70
72
  className={clsx(
71
73
  'absolute inset-0 bg-theme-base/60 backdrop-blur-sm',
72
74
  TRANSITION_BACKDROP,
73
- isOpen ? 'opacity-100' : 'opacity-0'
75
+ isOpen ? 'opacity-100' : 'opacity-0 pointer-events-none'
74
76
  )}
75
77
  style={overlayTransitionStyle(isOpen, 'dialog')}
76
- onClick={onClose}
78
+ onClick={isOpen ? onClose : undefined}
77
79
  />
78
80
 
79
81
  {/* Panel */}
@@ -2,6 +2,8 @@ import { useState, useMemo, useRef, useEffect, useCallback, forwardRef, useImper
2
2
  import { createPortal } from 'react-dom'
3
3
  import { Search, CornerDownLeft, Loader2, AlertTriangle } from 'lucide-react'
4
4
  import { clsx } from 'clsx'
5
+ import { useAnimatedUnmount } from '../../hooks/useAnimatedUnmount'
6
+ import { TRANSITION_BACKDROP, TRANSITION_MENU, TW_EASE_UI, overlayExitMs, overlayTransitionStyle } from '../../utils/animation'
5
7
  import { SearchPillInput, type SearchModifier } from '@skyhook-io/k8s-ui'
6
8
  import { getResourceIcon } from '../../utils/resource-icons'
7
9
  import type { SearchHit, SearchMatchedField } from '../../api/client'
@@ -386,8 +388,10 @@ export const Omnibar = forwardRef<OmnibarHandle, OmnibarProps>(function Omnibar(
386
388
  }, [open])
387
389
 
388
390
  // Track the input's position so the portaled panel stays anchored under it.
391
+ // The anchor is kept through the exit (clearing it at logical close would
392
+ // unmount the fading panel) and re-measured on the next open.
389
393
  useEffect(() => {
390
- if (!open) { setAnchor(null); return }
394
+ if (!open) return
391
395
  const update = () => {
392
396
  const el = containerRef.current
393
397
  if (!el) return
@@ -406,6 +410,15 @@ export const Omnibar = forwardRef<OmnibarHandle, OmnibarProps>(function Omnibar(
406
410
  const totalMatched = searchData?.total_matched ?? 0
407
411
  const hasNsPill = pills.some((p) => p.key === 'ns')
408
412
  const dropdownOpen = open && !suggesting && (rows.length > 0 || searchActive)
413
+ // Presence: the scrim and the results panel are menu-kind overlays (140ms
414
+ // in / 100ms out) and stay mounted through their exit. What the panel was
415
+ // showing at logical close is what fades out — `dropdownOpen` drops with
416
+ // `open`, so the last open-frame value is retained for the exit.
417
+ const portalShown = open && (dropdownOpen || suggesting)
418
+ const portal = useAnimatedUnmount(portalShown, overlayExitMs('menu'))
419
+ const panelWasOpen = useRef(dropdownOpen)
420
+ if (open) panelWasOpen.current = dropdownOpen
421
+ const showPanel = open ? dropdownOpen : panelWasOpen.current
409
422
 
410
423
  const clearNsPills = () => { setPills((prev) => prev.filter((p) => p.key !== 'ns')); inputRef.current?.focus() }
411
424
 
@@ -422,8 +435,8 @@ export const Omnibar = forwardRef<OmnibarHandle, OmnibarProps>(function Omnibar(
422
435
  >
423
436
  <SearchPillInput
424
437
  className={hero
425
- ? 'min-h-14 px-5 rounded-2xl bg-theme-surface border border-theme-border shadow-theme-sm transition-colors focus-within:border-[var(--color-brand-500)] focus-within:shadow-[0_0_0_4px_color-mix(in_srgb,var(--color-brand-500)_15%,transparent)]'
426
- : 'min-h-8 px-2.5 rounded-md bg-theme-elevated border border-transparent focus-within:border-theme-border focus-within:bg-theme-surface transition-colors'}
438
+ ? `min-h-14 px-5 rounded-2xl bg-theme-surface border border-theme-border shadow-theme-sm transition-[color,background-color,border-color,box-shadow] duration-[140ms] ${TW_EASE_UI} focus-within:border-[var(--color-brand-500)] focus-within:shadow-[0_0_0_4px_color-mix(in_srgb,var(--color-brand-500)_15%,transparent)]`
439
+ : `min-h-8 px-2.5 rounded-md bg-theme-elevated border border-transparent focus-within:border-theme-border focus-within:bg-theme-surface transition-[color,background-color,border-color] duration-[140ms] ${TW_EASE_UI}`}
427
440
  inputClassName={hero ? 'text-lg py-4' : undefined}
428
441
  text={text}
429
442
  pills={pills}
@@ -448,7 +461,7 @@ export const Omnibar = forwardRef<OmnibarHandle, OmnibarProps>(function Omnibar(
448
461
  }
449
462
  />
450
463
 
451
- {open && anchor && (dropdownOpen || suggesting) && createPortal(
464
+ {portal.shouldRender && anchor && createPortal(
452
465
  <>
453
466
  {/* Scrim — separates the dropdown from the page, consistently in both
454
467
  modes. At z-[15] it sits BELOW the rail/top bar (z-20/30), so the
@@ -459,15 +472,25 @@ export const Omnibar = forwardRef<OmnibarHandle, OmnibarProps>(function Omnibar(
459
472
  top-bar launcher covers from below the field (its box is already in
460
473
  the z-20 chrome). Click closes. */}
461
474
  <div
462
- className="fixed left-0 right-0 bottom-0 z-[15] bg-black/15 dark:bg-black/50 backdrop-blur-[3px]"
463
- style={{ top: hero ? 0 : anchor.top }}
464
- onClick={() => { setOpen(false); inputRef.current?.blur() }}
475
+ className={clsx(
476
+ 'fixed left-0 right-0 bottom-0 z-[15] bg-black/15 dark:bg-black/50 backdrop-blur-[3px]',
477
+ TRANSITION_BACKDROP,
478
+ portal.isOpen ? 'opacity-100' : 'opacity-0 pointer-events-none',
479
+ )}
480
+ style={{ top: hero ? 0 : anchor.top, ...overlayTransitionStyle(portal.isOpen, 'menu') }}
481
+ onClick={open ? () => { setOpen(false); inputRef.current?.blur() } : undefined}
465
482
  />
466
- {dropdownOpen && (
483
+ {showPanel && (
467
484
  <div
468
485
  ref={panelRef}
469
- style={{ position: 'fixed', top: anchor.top + 8, left: anchor.centerX, transform: 'translateX(-50%)', width: hero ? Math.round(anchor.width) : 640, maxWidth: 'calc(100vw - 2rem)' }}
470
- className="z-[121] dialog shadow-theme-lg ring-1 ring-black/5 dark:ring-white/10 overflow-hidden"
486
+ inert={!open || undefined}
487
+ style={{ position: 'fixed', top: anchor.top + 8, left: anchor.centerX, transform: 'translateX(-50%)', width: hero ? Math.round(anchor.width) : 640, maxWidth: 'calc(100vw - 2rem)', ...overlayTransitionStyle(portal.isOpen, 'menu') }}
488
+ className={clsx(
489
+ 'z-[121] dialog shadow-theme-lg ring-1 ring-black/5 dark:ring-white/10 overflow-hidden origin-top',
490
+ TRANSITION_MENU,
491
+ portal.isOpen ? 'opacity-100 translate-y-0 scale-100' : 'opacity-0 -translate-y-1 scale-[0.97]',
492
+ !open && 'pointer-events-none',
493
+ )}
471
494
  >
472
495
  <div ref={listRef} className="max-h-[60vh] overflow-y-auto py-1">
473
496
  {recentRows.length > 0 && (
@@ -182,8 +182,11 @@ export function ShortcutHelpOverlay({ onClose, currentView, isOpen = true }: Sho
182
182
  const shortcuts = useActiveShortcuts()
183
183
  const overlayRef = useRef<HTMLDivElement>(null)
184
184
 
185
- // Close on Escape
185
+ // Close on Escape — only while logically open: the overlay stays mounted
186
+ // through its exit and must not keep swallowing Escape (or blocking
187
+ // clicks) while it fades.
186
188
  useEffect(() => {
189
+ if (!isOpen) return
187
190
  const handler = (e: KeyboardEvent) => {
188
191
  if (e.key === 'Escape') {
189
192
  e.preventDefault()
@@ -194,7 +197,7 @@ export function ShortcutHelpOverlay({ onClose, currentView, isOpen = true }: Sho
194
197
  // Use capture to intercept before the shortcut system
195
198
  document.addEventListener('keydown', handler, true)
196
199
  return () => document.removeEventListener('keydown', handler, true)
197
- }, [onClose])
200
+ }, [isOpen, onClose])
198
201
 
199
202
  // Group shortcuts by category, merging duplicates with same description
200
203
  // (e.g., "Zoom in" registered for both + and = shows as one row)
@@ -224,16 +227,16 @@ export function ShortcutHelpOverlay({ onClose, currentView, isOpen = true }: Sho
224
227
  const isEmpty = globalCategories.length === 0 && !hasViewSection && !hasContextSection
225
228
 
226
229
  return (
227
- <div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
230
+ <div className="fixed inset-0 z-[100] flex items-center justify-center p-4" inert={!isOpen || undefined}>
228
231
  {/* Backdrop */}
229
232
  <div
230
233
  className={clsx(
231
234
  'absolute inset-0 bg-theme-base/70 backdrop-blur-sm',
232
235
  TRANSITION_BACKDROP,
233
- isOpen ? 'opacity-100' : 'opacity-0'
236
+ isOpen ? 'opacity-100' : 'opacity-0 pointer-events-none'
234
237
  )}
235
238
  style={overlayTransitionStyle(isOpen, 'dialog')}
236
- onClick={onClose}
239
+ onClick={isOpen ? onClose : undefined}
237
240
  />
238
241
 
239
242
  {/* Panel */}
@@ -1,47 +0,0 @@
1
- import { useState, type ReactNode } from 'react'
2
- import { clsx } from 'clsx'
3
- import { Collapse, CollapseChevron, useDisclosure } from '@skyhook-io/k8s-ui/components/ui/Collapse'
4
-
5
- // In-flow disclosure with the app-standard motion — the replacement for native
6
- // <details>/<summary> on primary paths. Native details snaps open with no
7
- // transition and its marker ignores the caret language; this pairs a button
8
- // header (aria-expanded / aria-controls via useDisclosure) with CollapseChevron
9
- // and an animated Collapse panel. Content stays mounted while closed, exactly
10
- // like native details, so server-rendered markup and in-page search still see
11
- // the collapsed copy.
12
- export function Disclosure({
13
- summary,
14
- children,
15
- className,
16
- summaryClassName,
17
- chevronClassName = 'h-3.5 w-3.5',
18
- defaultOpen = false,
19
- }: {
20
- summary: ReactNode
21
- children: ReactNode
22
- /** Wrapper (what the old <details> carried). */
23
- className?: string
24
- /** Header button (what the old <summary> carried). */
25
- summaryClassName?: string
26
- chevronClassName?: string
27
- defaultOpen?: boolean
28
- }) {
29
- const [open, setOpen] = useState(defaultOpen)
30
- const disclosure = useDisclosure(open)
31
- return (
32
- <div className={className}>
33
- <button
34
- type="button"
35
- {...disclosure.buttonProps}
36
- onClick={() => setOpen((value) => !value)}
37
- className={clsx('flex w-full cursor-pointer items-center gap-1.5 text-left', summaryClassName)}
38
- >
39
- <CollapseChevron open={open} className={chevronClassName} />
40
- {summary}
41
- </button>
42
- <Collapse open={open} id={disclosure.panelId}>
43
- {children}
44
- </Collapse>
45
- </div>
46
- )
47
- }