@skyhook-io/radar-app 1.0.1 → 1.1.0

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyhook-io/radar-app",
3
- "version": "1.0.1",
3
+ "version": "1.1.0",
4
4
  "description": "Radar's full web UI as a reusable React component. Used by Radar's own binary and by external consumers like Radar Cloud.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -35,7 +35,7 @@
35
35
  "react-virtuoso": "^4.18.6",
36
36
  "remark-gfm": "^4.0.1",
37
37
  "shiki": "^4.0.1",
38
- "yaml": "^2.8.4"
38
+ "yaml": "^2.9.0"
39
39
  },
40
40
  "peerDependencies": {
41
41
  "@skyhook-io/k8s-ui": ">=1.5.0",
@@ -53,10 +53,10 @@
53
53
  "@playwright/test": "^1.59.1",
54
54
  "@skyhook-io/k8s-ui": "*",
55
55
  "@tailwindcss/typography": "^0.5.19",
56
- "@tailwindcss/vite": "^4.2.4",
56
+ "@tailwindcss/vite": "^4.3.0",
57
57
  "@tanstack/react-query": "^5.100.9",
58
58
  "@types/diff": "^8.0.0",
59
- "@types/node": "^25.5.0",
59
+ "@types/node": "^25.7.0",
60
60
  "@types/react": "^19.2.14",
61
61
  "@types/react-dom": "^19.2.3",
62
62
  "@vitejs/plugin-react": "^6.0.1",
@@ -66,13 +66,13 @@
66
66
  "lucide-react": "^1.12.0",
67
67
  "postcss": "^8.5.14",
68
68
  "prettier": "^3.8.1",
69
- "react": "^19.2.5",
69
+ "react": "^19.2.6",
70
70
  "react-dom": "^19.2.5",
71
71
  "react-router-dom": "^7.15.0",
72
72
  "tailwind-merge": "^3.5.0",
73
73
  "tailwindcss": "^4.2.4",
74
74
  "typescript": "^6.0.2",
75
- "vite": "^8.0.10"
75
+ "vite": "^8.0.12"
76
76
  },
77
77
  "sideEffects": [
78
78
  "*.css"
package/src/App.tsx CHANGED
@@ -3,10 +3,10 @@ import { flushSync } from 'react-dom'
3
3
  import { useRefreshAnimation } from './hooks/useRefreshAnimation'
4
4
  import { startViewTransitionSafe } from '@skyhook-io/k8s-ui/utils/view-transition'
5
5
  import { useQueryClient } from '@tanstack/react-query'
6
- import { useNavigate, useLocation, useSearchParams } from 'react-router-dom'
6
+ import { useNavigate, useLocation, useSearchParams, useNavigationType, NavigationType } from 'react-router-dom'
7
7
  import { HomeView } from './components/home/HomeView'
8
8
  import { DebugOverlay } from './components/DebugOverlay'
9
- import { TopologyGraph, TopologyFilterSidebar, TopologyControls } from '@skyhook-io/k8s-ui'
9
+ import { TopologyGraph, TopologyFilterSidebar, TopologyControls, gitOpsRouteForKind } from '@skyhook-io/k8s-ui'
10
10
  import { TimelineView } from './components/timeline/TimelineView'
11
11
  import { ResourcesView } from './components/resources/ResourcesView'
12
12
  import { serializeColumnFilters } from './components/resources/resource-utils'
@@ -16,9 +16,10 @@ import { HelmView } from './components/helm/HelmView'
16
16
  import { TrafficView } from './components/traffic/TrafficView'
17
17
  import { CostView } from './components/cost/CostView'
18
18
  import { AuditView } from './components/audit/AuditView'
19
+ import { GitOpsView } from './components/gitops/GitOpsView'
19
20
  import { HelmReleaseDrawer } from './components/helm/HelmReleaseDrawer'
20
21
  import { PortForwardProvider, PortForwardIndicator, PortForwardPanel } from './components/portforward/PortForwardManager'
21
- import { DockProvider, BottomDock, useDock, useOpenLocalTerminal } from './components/dock'
22
+ import { DockProvider, BottomDock, useDock, useDockReservedHeight, useOpenLocalTerminal } from './components/dock'
22
23
  import { DURATION_DOCK } from '@skyhook-io/k8s-ui/utils/animation'
23
24
  import { ContextSwitcher } from './components/ContextSwitcher'
24
25
  import { NamespaceSwitcher, type NamespaceSwitcherHandle } from './components/NamespaceSwitcher'
@@ -34,18 +35,18 @@ import { ShortcutHelpOverlay } from './components/ui/ShortcutHelpOverlay'
34
35
  import { CommandPalette } from './components/ui/CommandPalette'
35
36
  import { DiagnosticsOverlay } from './components/ui/DiagnosticsOverlay'
36
37
  import { useEventSource } from './hooks/useEventSource'
37
- import { useNamespaces, useNamespaceScope, useSetActiveNamespace, useSwitchContext, useAuthMe } from './api/client'
38
+ import { debugNamespaceLog, useNamespaces, useNamespaceScope, useSetActiveNamespace, useSwitchContext, useAuthMe } from './api/client'
38
39
  import { routePath, apiUrl, getAuthHeaders, getCredentialsMode } from './api/config'
39
40
  import { KeyboardShortcutProvider, useRegisterShortcut, useRegisterShortcuts } from './hooks/useKeyboardShortcuts'
40
41
  import { useAnimatedUnmount } from './hooks/useAnimatedUnmount'
41
42
  import radarLoadingIcon from '@skyhook-io/k8s-ui/assets/radar/radar-icon-loading.svg'
42
- import { RefreshCw, Network, List, Clock, Package, Sun, Moon, Activity, Home, Star, Search, Bug, Settings, SquareTerminal, ShieldCheck } from 'lucide-react'
43
+ import { RefreshCw, Network, List, Clock, Package, Sun, Moon, Activity, Home, Star, Search, Bug, Settings, SquareTerminal, ShieldCheck, GitBranch } from 'lucide-react'
43
44
  import { useTheme } from './context/ThemeContext'
44
45
  import { Tooltip } from './components/ui/Tooltip'
45
46
  import { LargeClusterNamespacePicker } from './components/shared/LargeClusterNamespacePicker'
46
47
  import { SettingsDialog } from './components/settings/SettingsDialog'
47
48
  import type { TopologyNode, GroupingMode, MainView, SelectedResource, SelectedHelmRelease, NodeKind, TopologyMode, Topology, K8sEvent } from './types'
48
- import { kindToPlural, openExternal } from './utils/navigation'
49
+ import { kindToPlural, openExternal, apiVersionToGroup, buildWorkloadPath } from './utils/navigation'
49
50
  import type { ContextSwitcherHandle } from './components/ContextSwitcher'
50
51
 
51
52
  // All possible node kinds (core + GitOps)
@@ -115,7 +116,7 @@ function apiResourceToNodeIdPrefix(apiResource: string): string {
115
116
  }
116
117
 
117
118
  // Extended MainView type that includes traffic and cost
118
- type ExtendedMainView = MainView | 'traffic' | 'cost' | 'workload' | 'audit'
119
+ type ExtendedMainView = MainView | 'traffic' | 'cost' | 'workload' | 'audit' | 'gitops'
119
120
 
120
121
  // Extract view from URL path
121
122
  function getViewFromPath(pathname: string): ExtendedMainView {
@@ -129,6 +130,7 @@ function getViewFromPath(pathname: string): ExtendedMainView {
129
130
  if (path === 'cost') return 'cost'
130
131
  if (path === 'workload') return 'workload'
131
132
  if (path === 'audit') return 'audit'
133
+ if (path === 'gitops') return 'gitops'
132
134
  return 'home'
133
135
  }
134
136
 
@@ -173,6 +175,7 @@ function AuthBarrier({ authMode }: { authMode: string }) {
173
175
  function AppInner() {
174
176
  const navigate = useNavigate()
175
177
  const location = useLocation()
178
+ const navigationType = useNavigationType()
176
179
  const [searchParams, setSearchParams] = useSearchParams()
177
180
  const capabilities = useCapabilitiesContext()
178
181
  const openLocalTerminal = useOpenLocalTerminal()
@@ -394,7 +397,7 @@ function AppInner() {
394
397
  const contextSwitcherRef = useRef<ContextSwitcherHandle>(null)
395
398
 
396
399
  // View switching keyboard shortcuts
397
- const views: ExtendedMainView[] = ['home', 'topology', 'resources', 'timeline', 'helm', 'traffic', 'cost', 'audit']
400
+ const views: ExtendedMainView[] = ['home', 'topology', 'resources', 'timeline', 'helm', 'gitops', 'traffic', 'cost', 'audit']
398
401
  useRegisterShortcuts([
399
402
  ...views.map((view, i) => ({
400
403
  id: `view-${view}`,
@@ -541,6 +544,16 @@ function AppInner() {
541
544
  if (pending.kinds.has('secrets')) {
542
545
  queryClient.invalidateQueries({ queryKey: ['secret-cert-expiry'] })
543
546
  }
547
+ // GitOps tree + insights are derived views over the same informer
548
+ // cache that produced this SSE event — when *anything* changes, the
549
+ // managed-resource tree and the insights pipeline can have stale
550
+ // changes/events/drift. Invalidating broadly here is cheap (only the
551
+ // currently-mounted GitOps view re-fetches; other views have no
552
+ // matching keys) and is what makes the detail page actually live.
553
+ // Without this the failure card + topology lag behind the title chips
554
+ // until window focus or a manual refresh.
555
+ queryClient.invalidateQueries({ queryKey: ['gitops-tree'] })
556
+ queryClient.invalidateQueries({ queryKey: ['gitops-insights'] })
544
557
  // Reset accumulator
545
558
  pending.kinds = new Set()
546
559
  pending.hasCountChange = false
@@ -591,6 +604,26 @@ function AppInner() {
591
604
  }, forceNamespaceFilter, showPolicyEffect)
592
605
  const [reconnect, isReconnecting] = useRefreshAnimation(reconnectSSE)
593
606
 
607
+ // On large clusters (where the server requires namespace filtering), keep
608
+ // SSE's server-side filter in lockstep with the user's namespace pick.
609
+ // Without this, header switches and deep-link loads can leave SSE filtered
610
+ // to a stale namespace while sidebar/topology show a different one. Small
611
+ // clusters never set forceNamespaceFilter and skip this path entirely.
612
+ useEffect(() => {
613
+ const isLarge = forceNamespaceFilter !== undefined || topology?.requiresNamespaceFilter === true
614
+ if (!isLarge) return
615
+ if (namespaces.length === 0) {
616
+ setForceNamespaceFilter(prev => (prev === undefined ? prev : undefined))
617
+ return
618
+ }
619
+ setForceNamespaceFilter(prev => {
620
+ const cur = prev ? [...prev].sort() : []
621
+ const next = [...namespaces].sort()
622
+ if (cur.length === next.length && cur.every((ns, i) => ns === next[i])) return prev
623
+ return [...namespaces]
624
+ })
625
+ }, [namespaces, forceNamespaceFilter, topology?.requiresNamespaceFilter])
626
+
594
627
  // Apply live topology updates only when not paused. While paused, buffer the
595
628
  // latest snapshot so we can apply it instantly when the user resumes.
596
629
  useEffect(() => {
@@ -656,12 +689,24 @@ function AppInner() {
656
689
  // TODO: Could show a list of pods in the group
657
690
  if (node.kind === 'PodGroup') return
658
691
 
692
+ const namespace = (node.data.namespace as string) || ''
693
+ // GitOps CRs (Application/Kustomization/HelmRelease/etc.) have a dedicated
694
+ // detail page with tree + insights + ops that the drawer can't reproduce.
695
+ // Route there from the main topology when the node is one of those kinds;
696
+ // everything else falls back to the drawer.
697
+ const gitOpsPath = gitOpsRouteForKind(node.kind, namespace, node.name)
698
+ if (gitOpsPath) {
699
+ navigate(gitOpsPath)
700
+ return
701
+ }
702
+
659
703
  navigateToResource({
660
704
  kind: kindToPlural(node.kind),
661
- namespace: (node.data.namespace as string) || '',
705
+ namespace,
662
706
  name: node.name,
707
+ group: apiVersionToGroup(node.data.apiVersion as string | undefined),
663
708
  })
664
- }, [])
709
+ }, [navigate])
665
710
 
666
711
  // Serialize namespaces for stable dependency tracking
667
712
  const namespacesKey = namespaces.join(',')
@@ -679,6 +724,12 @@ function AppInner() {
679
724
  const sortedScope = [...scopeActives].sort()
680
725
  const sortedState = [...namespaces].sort()
681
726
  const sameAsState = sortedScope.length === sortedState.length && sortedScope.every((ns, i) => ns === sortedState[i])
727
+ debugNamespaceLog('app:scope-mirror', {
728
+ scopeActives,
729
+ stateNamespaces: namespaces,
730
+ sameAsState,
731
+ initialBookmarkReconciled: initialBookmarkReconciledRef.current,
732
+ })
682
733
 
683
734
  // First-load bookmark reconciliation: if the URL had namespaces that
684
735
  // differ from the server pick when the scope first arrives, push the
@@ -688,12 +739,17 @@ function AppInner() {
688
739
  if (!initialBookmarkReconciledRef.current) {
689
740
  initialBookmarkReconciledRef.current = true
690
741
  if (!sameAsState && sortedState.length > 0) {
742
+ debugNamespaceLog('app:scope-mirror-bookmark-to-server', {
743
+ stateNamespaces: sortedState,
744
+ scopeActives: sortedScope,
745
+ })
691
746
  setActiveNamespace.mutate({ namespaces: sortedState })
692
747
  return
693
748
  }
694
749
  }
695
750
 
696
751
  if (!sameAsState) {
752
+ debugNamespaceLog('app:scope-mirror-set-namespaces', { nextNamespaces: scopeActives })
697
753
  setNamespaces(scopeActives)
698
754
  }
699
755
  // eslint-disable-next-line react-hooks/exhaustive-deps -- namespaces and setActiveNamespace are intentionally excluded; we only react to server-side changes.
@@ -734,31 +790,48 @@ function AppInner() {
734
790
 
735
791
  // Only update if params actually changed vs current URL
736
792
  if (params.toString() !== new URLSearchParams(currentSearch).toString()) {
793
+ debugNamespaceLog('app:url-write', {
794
+ namespaces,
795
+ currentSearch,
796
+ nextSearch: params.toString(),
797
+ mainView,
798
+ })
737
799
  setSearchParams(params, { replace: true })
738
800
  }
739
801
  // eslint-disable-next-line react-hooks/exhaustive-deps -- reads window.location.search, not searchParams
740
802
  }, [namespacesKey, topologyMode, groupingMode, mainView, setSearchParams])
741
803
 
742
- // Sync state from URL when navigating (back/forward). Push the URL choice
743
- // to the server too so the canonical pick stays in lockstep otherwise a
744
- // back-nav would visually update but leave the next reload pulling the
745
- // server's previous (forward-nav) pick.
804
+ // Sync namespace + helm picks from the query string only when the query
805
+ // string changes. If this also ran on pathname / mainView changes, a view
806
+ // whose URL omits ?namespaces= would clear App state and POST [] to the
807
+ // server while the per-user pick was still narrowed — the picker would
808
+ // show the server scope but lists/dashboard would stay on "all namespaces".
746
809
  useEffect(() => {
747
810
  const urlNamespaces = parseNamespacesFromURL(searchParams)
811
+ debugNamespaceLog('app:url-sync', {
812
+ search: searchParams.toString(),
813
+ urlNamespaces,
814
+ stateNamespaces: namespaces,
815
+ namespacesKey,
816
+ })
748
817
 
749
818
  if (urlNamespaces.join(',') !== namespacesKey) {
819
+ debugNamespaceLog('app:url-sync-set-namespaces', { nextNamespaces: urlNamespaces })
750
820
  setNamespaces(urlNamespaces)
751
821
  if (namespaceScope) {
752
822
  const sortedURL = [...urlNamespaces].sort()
753
823
  const sortedScope = [...(namespaceScope.actives ?? [])].sort()
754
824
  const same = sortedURL.length === sortedScope.length && sortedURL.every((ns, i) => ns === sortedScope[i])
755
825
  if (!same) {
826
+ debugNamespaceLog('app:url-sync-mutate-server', {
827
+ urlNamespaces,
828
+ scopeActives: namespaceScope.actives ?? [],
829
+ })
756
830
  setActiveNamespace.mutate({ namespaces: urlNamespaces })
757
831
  }
758
832
  }
759
833
  }
760
834
 
761
- // Restore helm release from URL (back navigation)
762
835
  const releaseParam = searchParams.get('release')
763
836
  if (releaseParam) {
764
837
  const slashIdx = releaseParam.indexOf('/')
@@ -768,8 +841,34 @@ function AppInner() {
768
841
  setSelectedHelmRelease({ namespace: ns, name, storageNamespace: searchParams.get('releaseStorage') || undefined })
769
842
  }
770
843
  }
844
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- run only when searchParams change; namespacesKey/namespaceScope are read for that transition
771
845
  }, [searchParams])
772
846
 
847
+ useEffect(() => {
848
+ if (navigationType !== NavigationType.Pop || mainView !== 'resources') return
849
+ const kindFromPath = location.pathname.match(/^\/resources\/([^/]+)/)?.[1] ?? ''
850
+ const resourceParam = searchParams.get('resource')
851
+ if (kindFromPath && resourceParam) {
852
+ const slashIdx = resourceParam.indexOf('/')
853
+ const ns = slashIdx > 0 ? resourceParam.slice(0, slashIdx) : ''
854
+ const name = slashIdx > 0 ? resourceParam.slice(slashIdx + 1) : resourceParam
855
+ const apiGroup = searchParams.get('apiGroup') ?? ''
856
+ const next: SelectedResource = { kind: kindFromPath, namespace: ns, name, group: apiGroup }
857
+ setSelectedResource(prev => {
858
+ if (
859
+ prev &&
860
+ prev.kind === next.kind &&
861
+ prev.namespace === next.namespace &&
862
+ prev.name === next.name &&
863
+ (prev.group ?? '') === (next.group ?? '')
864
+ ) return prev
865
+ return next
866
+ })
867
+ } else if (kindFromPath && !resourceParam) {
868
+ setSelectedResource(prev => (prev === null ? prev : null))
869
+ }
870
+ }, [navigationType, mainView, location.pathname, searchParams])
871
+
773
872
  // Auto-adjust grouping when namespaces change
774
873
  useEffect(() => {
775
874
  if (namespaces.length === 0 && groupingMode === 'none') {
@@ -918,7 +1017,7 @@ function AppInner() {
918
1017
  collide with the absolute-centered nav block at xl, which
919
1018
  is the same breakpoint where nav labels appear. */}
920
1019
  {(!connected || crdDiscoveryStatus === 'discovering') && (
921
- <span className="text-xs text-theme-text-tertiary hidden xl:inline">
1020
+ <span className="text-[11px] text-theme-text-tertiary hidden xl:inline">
922
1021
  {!connected ? 'Disconnected' : 'Discovering Custom Resources...'}
923
1022
  </span>
924
1023
  )}
@@ -939,13 +1038,14 @@ function AppInner() {
939
1038
  </div>
940
1039
 
941
1040
  {/* Center: View tabs — absolute centered on wide, flows after left section on narrow */}
942
- <div className="md:absolute md:left-1/2 md:-translate-x-1/2 flex items-center gap-1 bg-theme-elevated/50 rounded-full p-1 ml-2 md:ml-0">
1041
+ <div className="md:absolute md:left-1/2 md:-translate-x-1/2 flex items-center gap-0.5 bg-theme-elevated/50 rounded-full p-1 ml-2 md:ml-0">
943
1042
  {([
944
1043
  { view: 'home' as const, icon: Home, label: 'Home' },
945
1044
  { view: 'topology' as const, icon: Network, label: 'Topology' },
946
1045
  { view: 'resources' as const, icon: List, label: 'Resources' },
947
1046
  { view: 'timeline' as const, icon: Clock, label: 'Timeline' },
948
1047
  { view: 'helm' as const, icon: Package, label: 'Helm' },
1048
+ { view: 'gitops' as const, icon: GitBranch, label: 'GitOps' },
949
1049
  { view: 'traffic' as const, icon: Activity, label: 'Traffic' },
950
1050
  // Cost is intentionally hidden from the pill bar for now — the view still
951
1051
  // exists and is reachable via /cost, the Home dashboard card, and the
@@ -955,7 +1055,7 @@ function AppInner() {
955
1055
  <Tooltip key={view} content={label} delay={100} position="bottom">
956
1056
  <button
957
1057
  onClick={() => setMainView(view)}
958
- className={`flex items-center gap-1.5 px-2.5 py-1.5 text-sm rounded-full transition-colors ${
1058
+ className={`flex items-center gap-1 px-2 py-1 text-[13px] rounded-full transition-colors ${
959
1059
  mainView === view
960
1060
  ? 'bg-skyhook-600 dark:bg-skyhook-500 text-white shadow-glow-brand-sm'
961
1061
  : 'text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-hover'
@@ -1291,7 +1391,7 @@ function AppInner() {
1291
1391
  <TimelineView
1292
1392
  namespaces={namespaces}
1293
1393
  onResourceClick={(resource) => {
1294
- navigate(`/workload/${resource.kind}/${resource.namespace}/${resource.name}`)
1394
+ navigate(buildWorkloadPath(resource))
1295
1395
  }}
1296
1396
  initialViewMode={(searchParams.get('view') as 'list' | 'swimlane') || undefined}
1297
1397
  initialFilter={(searchParams.get('filter') as 'all' | 'changes' | 'k8s_events' | 'warnings' | 'unhealthy') || undefined}
@@ -1324,6 +1424,16 @@ function AppInner() {
1324
1424
  />
1325
1425
  )}
1326
1426
 
1427
+ {/* GitOps view */}
1428
+ {mainView === 'gitops' && (
1429
+ <GitOpsView
1430
+ namespaces={namespaces}
1431
+ onOpenResource={(resource) => {
1432
+ setSelectedResource(resource)
1433
+ }}
1434
+ />
1435
+ )}
1436
+
1327
1437
  {/* Traffic view */}
1328
1438
  {mainView === 'traffic' && (
1329
1439
  <TrafficView namespaces={namespaces} />
@@ -1361,7 +1471,7 @@ function AppInner() {
1361
1471
  {mainView === 'workload' && !drawerExpanded && (
1362
1472
  <WorkloadViewRoute
1363
1473
  onNavigateToResource={(resource) => {
1364
- navigate(`/workload/${resource.kind}/${resource.namespace}/${resource.name}`)
1474
+ navigate(buildWorkloadPath(resource))
1365
1475
  }}
1366
1476
  />
1367
1477
  )}
@@ -1381,12 +1491,12 @@ function AppInner() {
1381
1491
  onExpand={(res) => {
1382
1492
  suppressViewClearRef.current = true
1383
1493
  setDrawerExpanded(true)
1384
- navigate(`/workload/${res.kind}/${res.namespace}/${res.name}`)
1494
+ navigate(buildWorkloadPath(res))
1385
1495
  }}
1386
1496
  onCollapse={handleCollapseFromExpanded}
1387
1497
  onNavigateToResource={(resource) => {
1388
1498
  setSelectedResource(resource)
1389
- navigate(`/workload/${resource.kind}/${resource.namespace}/${resource.name}`, { replace: true })
1499
+ navigate(buildWorkloadPath(resource), { replace: true })
1390
1500
  }}
1391
1501
  />
1392
1502
  )}
@@ -1404,11 +1514,11 @@ function AppInner() {
1404
1514
  setSearchParams(params, { replace: true })
1405
1515
  }}
1406
1516
  onNavigateToResource={(resource) => {
1407
- // Navigate to resources view with kind in path and open the resource detail drawer
1408
1517
  setSelectedHelmRelease(null)
1409
1518
  const newParams = new URLSearchParams()
1410
1519
  const globalNamespaces = searchParams.get('namespaces')
1411
1520
  if (globalNamespaces) newParams.set('namespaces', globalNamespaces)
1521
+ if (resource.group) newParams.set('apiGroup', resource.group)
1412
1522
  navigate({ pathname: `/resources/${resource.kind}`, search: newParams.toString() })
1413
1523
  setSelectedResource(resource)
1414
1524
  }}
@@ -1484,11 +1594,20 @@ function AppInner() {
1484
1594
 
1485
1595
  // Spacer component that adds padding when dock is open
1486
1596
  function DockSpacer() {
1487
- const { tabs, isExpanded } = useDock()
1597
+ const { tabs, isResizing } = useDock()
1598
+ const dockInset = useDockReservedHeight()
1488
1599
  const location = useLocation()
1489
1600
  // Traffic view manages its own layout — spacer would break its flex sizing
1490
1601
  if (tabs.length === 0 || location.pathname === '/traffic') return null
1491
- return <div className="shrink-0" style={{ height: isExpanded ? 300 : 36, transition: `height ${DURATION_DOCK}ms cubic-bezier(0.4, 0, 0.2, 1)` }} />
1602
+ return (
1603
+ <div
1604
+ className="shrink-0"
1605
+ style={{
1606
+ height: dockInset,
1607
+ transition: isResizing ? 'none' : `height ${DURATION_DOCK}ms cubic-bezier(0.4, 0, 0.2, 1)`,
1608
+ }}
1609
+ />
1610
+ )
1492
1611
  }
1493
1612
 
1494
1613
  // Floating action buttons that position themselves above the dock