Skip to content

Commit 32cc002

Browse files
committed
fix(header): remove assistantPanelTopOffset prop and simplify Header component structure for improved clarity
1 parent ffba8e0 commit 32cc002

File tree

12 files changed

+166
-209
lines changed

12 files changed

+166
-209
lines changed

apps/console-v5/src/app/components/header/header.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ export function Separator() {
2222
)
2323
}
2424

25-
export function Header({ assistantPanelTopOffset }: { assistantPanelTopOffset?: number }) {
25+
export function Header() {
2626
const { organizationId = '' } = useParams({ strict: false })
2727
const isDevopsCopilotEnabled = useFeatureFlagVariantKey('devops-copilot')
2828
const handleFeedbackClick = useCallback(() => {
@@ -50,7 +50,7 @@ export function Header({ assistantPanelTopOffset }: { assistantPanelTopOffset?:
5050
<Button onClick={handleFeedbackClick} variant="outline">
5151
Feedback
5252
</Button>
53-
<AssistantTrigger panelTopOffset={assistantPanelTopOffset} />
53+
<AssistantTrigger />
5454
{isDevopsCopilotEnabled && <DevopsCopilotButton />}
5555
<UserMenu />
5656
</div>

apps/console-v5/src/routes/_authenticated/organization/route.tsx

Lines changed: 61 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,10 @@ import { Suspense, useEffect, useLayoutEffect, useRef, useState } from 'react'
55
import { useEnvironment } from '@qovery/domains/environments/feature'
66
import { useProject } from '@qovery/domains/projects/feature'
77
import { useRecentServices, useServiceSummary } from '@qovery/domains/services/feature'
8-
import { AssistantProvider } from '@qovery/shared/assistant/feature'
8+
import { AssistantPanelOutlet, AssistantProvider } from '@qovery/shared/assistant/feature'
99
import { DevopsCopilotContext } from '@qovery/shared/devops-copilot/context'
1010
import { DevopsCopilotTrigger } from '@qovery/shared/devops-copilot/feature'
1111
import { ErrorBoundary, Icon, Link, LoaderSpinner, Navbar } from '@qovery/shared/ui'
12-
import { useStickyBottomOffset } from '@qovery/shared/util-hooks'
1312
import { queries } from '@qovery/state/util-queries'
1413
import Header from '../../../app/components/header/header'
1514
import { NotFoundPage } from '../../../app/components/not-found-page/not-found-page'
@@ -461,7 +460,7 @@ function OrganizationRoute() {
461460
enabled: Boolean(environmentId) && Boolean(serviceId),
462461
})
463462
const scrollContainerRef = useRef<HTMLDivElement>(null)
464-
const [setNavbarRef, assistantPanelTopOffset] = useStickyBottomOffset(scrollContainerRef)
463+
const assistantAnchorRef = useRef<HTMLDivElement>(null)
465464
const [devopsCopilotOpen, setDevopsCopilotOpen] = useState(false)
466465
const sendMessageRef = useRef<((message: string, createNewChat?: boolean) => void) | null>(null)
467466

@@ -507,11 +506,50 @@ function OrganizationRoute() {
507506
}
508507
}, [service?.id, project?.id, environment?.id])
509508

510-
// Reset scroll on route change; the sticky-bottom hook reacts via its scroll listener.
511509
useLayoutEffect(() => {
512510
scrollContainerRef.current?.scrollTo({ top: 0 })
513511
}, [location.pathname])
514512

513+
/**
514+
* Sync the assistant panel's available height with the sticky wrapper's actual top in the viewport.
515+
*
516+
* CSS sticky handles the panel's top position perfectly (no JS for that), but its height cannot
517+
* be expressed in pure CSS because it depends on the wrapper's current top in viewport — which
518+
* varies while the header is scrolling away. We only write a CSS variable on the anchor element
519+
* so downstream styles stay declarative and React does not re-render on every scroll frame.
520+
*
521+
* We intentionally do not throttle with rAF here: the scroll handler must run in the same frame
522+
* as the browser's own scroll commit, otherwise the height lags one frame behind the sticky top
523+
* and causes a visible judder. getBoundingClientRect + setProperty are cheap and the handler is
524+
* passive, so this stays well within a frame budget.
525+
*/
526+
useLayoutEffect(() => {
527+
const scrollContainer = scrollContainerRef.current
528+
const anchor = assistantAnchorRef.current
529+
530+
if (!scrollContainer || !anchor) {
531+
return
532+
}
533+
534+
const update = () => {
535+
const top = Math.max(0, anchor.getBoundingClientRect().top)
536+
anchor.style.setProperty('--assistant-panel-top', `${top}px`)
537+
}
538+
539+
const resizeObserver = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(update)
540+
resizeObserver?.observe(scrollContainer)
541+
542+
scrollContainer.addEventListener('scroll', update, { passive: true })
543+
window.addEventListener('resize', update)
544+
update()
545+
546+
return () => {
547+
scrollContainer.removeEventListener('scroll', update)
548+
window.removeEventListener('resize', update)
549+
resizeObserver?.disconnect()
550+
}
551+
}, [])
552+
515553
if (bypassLayout) {
516554
return (
517555
<DevopsCopilotContext.Provider
@@ -550,22 +588,35 @@ function OrganizationRoute() {
550588
<AssistantProvider>
551589
<div className="bg-background flex h-dvh w-full flex-col">
552590
{/* TODO: Conflicts with body main:not(.h-screen, .layout-onboarding) */}
553-
<div ref={scrollContainerRef} className="min-h-0 flex-1 overflow-auto">
591+
<div ref={scrollContainerRef} className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden">
554592
<ErrorBoundary>
555593
<OrganizationBanners />
556-
<Header assistantPanelTopOffset={assistantPanelTopOffset} />
594+
<Header />
557595

558596
<Suspense fallback={<MainLoader />}>
559597
<>
560-
<div
561-
ref={setNavbarRef}
562-
className="z-header border-neutral bg-background-secondary sticky top-0 border-b px-4"
563-
>
598+
<div className="z-header border-neutral bg-background-secondary sticky top-0 border-b px-4">
564599
<Navbar.Root activeId={activeTabId} className="container relative top-[1px] mx-0 -mt-[1px]">
565600
{navigationContext && <NavigationBar context={navigationContext} />}
566601
</Navbar.Root>
567602
</div>
568603

604+
<div
605+
ref={assistantAnchorRef}
606+
className="pointer-events-none sticky top-[calc(2.75rem+1px)] z-overlay h-0"
607+
>
608+
<div
609+
className="pointer-events-auto absolute right-0 top-0 isolate"
610+
style={{
611+
// JS updates --assistant-panel-top to the anchor's current top in the viewport.
612+
// Fallback matches the stuck state so SSR/first paint render a reasonable size.
613+
height: 'calc(100dvh - var(--assistant-panel-top, calc(2.75rem + 1px)))',
614+
}}
615+
>
616+
<AssistantPanelOutlet />
617+
</div>
618+
</div>
619+
569620
<div className={needsFullWidth ? 'min-h-0' : 'container mx-auto min-h-0 px-4'}>
570621
{isServiceNotFound ? (
571622
<NotFoundPage

libs/pages/layout/src/lib/ui/layout-page/layout-page.tsx

Lines changed: 36 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import { match } from 'ts-pattern'
77
import { ClusterDeploymentProgressCard, useClusterStatuses } from '@qovery/domains/clusters/feature'
88
import { useAlerts } from '@qovery/domains/observability/feature'
99
import { FreeTrialBanner, InvoiceBanner, useOrganization } from '@qovery/domains/organizations/feature'
10-
import { AssistantProvider, AssistantTrigger } from '@qovery/shared/assistant/feature'
1110
import { DevopsCopilotButton, DevopsCopilotTrigger } from '@qovery/shared/devops-copilot/feature'
1211
import {
1312
getNewConsoleUrl,
@@ -202,47 +201,44 @@ export function LayoutPage(props: PropsWithChildren<LayoutPageProps>) {
202201
alertingNotification={hasFiringAlerts ? 'error' : undefined}
203202
/>
204203
</div>
205-
<AssistantProvider>
206-
<div className="flex w-full grow flex-col-reverse">
207-
<div>
208-
<div
209-
className={`relative flex ${
210-
clusterCredentialError ? 'min-h-page-container-wbanner' : 'min-h-page-container'
211-
}`}
212-
>
213-
<div className="flex grow flex-col px-2 pt-2 dark:px-0 dark:pt-0">{children}</div>
214-
<AssistantTrigger />
215-
{isFeatureFlag && <DevopsCopilotTrigger />}
216-
</div>
204+
<div className="flex w-full grow flex-col-reverse">
205+
<div>
206+
<div
207+
className={`relative flex ${
208+
clusterCredentialError ? 'min-h-page-container-wbanner' : 'min-h-page-container'
209+
}`}
210+
>
211+
<div className="flex grow flex-col px-2 pt-2 dark:px-0 dark:pt-0">{children}</div>
212+
{isFeatureFlag && <DevopsCopilotTrigger />}
217213
</div>
218-
{clusterCredentialError && (
219-
<Banner
220-
color="yellow"
221-
onClickButton={() =>
222-
navigate(
223-
CLUSTER_URL(organizationId, invalidCluster?.id) +
224-
CLUSTER_SETTINGS_URL +
225-
CLUSTER_SETTINGS_CREDENTIALS_URL
226-
)
227-
}
228-
buttonLabel="Check the credentials configuration"
229-
>
230-
The credentials for the cluster <span className="mx-1 block font-bold">{invalidCluster?.name}</span>{' '}
231-
are invalid.
232-
</Banner>
233-
)}
234-
<FreeTrialBanner />
235-
<InvoiceBanner />
236-
{topBar && (
237-
<TopBar>
238-
<div className="flex items-center">
239-
{spotlight && <SpotlightTrigger />}
240-
{isFeatureFlag && <DevopsCopilotButton />}
241-
</div>
242-
</TopBar>
243-
)}
244214
</div>
245-
</AssistantProvider>
215+
{clusterCredentialError && (
216+
<Banner
217+
color="yellow"
218+
onClickButton={() =>
219+
navigate(
220+
CLUSTER_URL(organizationId, invalidCluster?.id) +
221+
CLUSTER_SETTINGS_URL +
222+
CLUSTER_SETTINGS_CREDENTIALS_URL
223+
)
224+
}
225+
buttonLabel="Check the credentials configuration"
226+
>
227+
The credentials for the cluster <span className="mx-1 block font-bold">{invalidCluster?.name}</span> are
228+
invalid.
229+
</Banner>
230+
)}
231+
<FreeTrialBanner />
232+
<InvoiceBanner />
233+
{topBar && (
234+
<TopBar>
235+
<div className="flex items-center">
236+
{spotlight && <SpotlightTrigger />}
237+
{isFeatureFlag && <DevopsCopilotButton />}
238+
</div>
239+
</TopBar>
240+
)}
241+
</div>
246242
</div>
247243
{showFloatingDeploymentCard && (
248244
<ClusterDeploymentProgressCard organizationId={organizationId} clusters={deployingClusters} />

libs/pages/services/src/lib/feature/page-helm-create-feature/page-helm-create-feature.tsx

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ import { type UseFormReturn, useForm } from 'react-hook-form'
99
import { Navigate, Route, Routes, useNavigate, useParams } from 'react-router-dom'
1010
import { type HelmValuesArgumentsData, type HelmValuesFileData } from '@qovery/domains/service-helm/feature'
1111
import { serviceTemplates } from '@qovery/domains/services/feature'
12-
import { AssistantTrigger } from '@qovery/shared/assistant/feature'
1312
import {
1413
SERVICES_HELM_CREATION_GENERAL_URL,
1514
SERVICES_HELM_CREATION_URL,
@@ -119,7 +118,6 @@ export function PageHelmCreateFeature() {
119118
<Route path="*" element={<Navigate replace to={creationFlowUrl + SERVICES_HELM_CREATION_GENERAL_URL} />} />
120119
)}
121120
</Routes>
122-
<AssistantTrigger defaultOpen />
123121
</FunnelFlow>
124122
</HelmCreateContext.Provider>
125123
)

libs/pages/services/src/lib/feature/page-job-create-feature/page-job-create-feature.tsx

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import { type UseFormReturn, useForm } from 'react-hook-form'
44
import { Navigate, Route, Routes, useLocation, useNavigate, useParams } from 'react-router-dom'
55
import { type DockerfileSettingsData } from '@qovery/domains/services/feature'
66
import { type ServiceTemplateOptionType, serviceTemplates } from '@qovery/domains/services/feature'
7-
import { AssistantTrigger } from '@qovery/shared/assistant/feature'
87
import { type JobType, ServiceTypeEnum } from '@qovery/shared/enums'
98
import {
109
type FlowVariableData,
@@ -166,7 +165,6 @@ export function PageJobCreateFeature() {
166165
/>
167166
)}
168167
</Routes>
169-
<AssistantTrigger defaultOpen />
170168
</FunnelFlow>
171169
)
172170

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export * from './lib/assistant-trigger/assistant-trigger'
2+
export * from './lib/assistant-panel-outlet/assistant-panel-outlet'
23
export * from './lib/need-help/need-help'
34
export * from './lib/assistant-context/assistant-context'
45
export * from './lib/hooks/use-contextual-doc-links/use-contextual-doc-links'
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { AnimatePresence } from 'framer-motion'
2+
import { useAssistantOpen, useSetAssistantOpen } from '../assistant-context/assistant-context'
3+
import { AssistantPanel } from '../assistant-panel/assistant-panel'
4+
5+
/**
6+
* Renders the assistant panel at the location where it is mounted, driven by the
7+
* shared AssistantProvider state. The caller is responsible for wrapping this in the
8+
* correct sticky container right after the sticky navbar.
9+
*/
10+
export function AssistantPanelOutlet() {
11+
const assistantOpen = useAssistantOpen()
12+
const setAssistantOpen = useSetAssistantOpen()
13+
14+
return (
15+
<AnimatePresence>{assistantOpen && <AssistantPanel onClose={() => setAssistantOpen(false)} />}</AnimatePresence>
16+
)
17+
}
18+
19+
export default AssistantPanelOutlet

libs/shared/assistant/feature/src/lib/assistant-panel/assistant-panel.tsx

Lines changed: 24 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { motion, useReducedMotion } from 'framer-motion'
22
import posthog from 'posthog-js'
33
import { useEffect, useState } from 'react'
4-
import { createPortal } from 'react-dom'
54
import { match } from 'ts-pattern'
65
import { ExternalLink, Icon, InputSearch, LoaderSpinner } from '@qovery/shared/ui'
76
import { QOVERY_STATUS_URL } from '@qovery/shared/util-const'
@@ -15,14 +14,9 @@ import { MintlifyHit } from '../mintlify-hit/mintlify-hit'
1514

1615
export interface AssistantPanelProps {
1716
onClose: () => void
18-
smaller?: boolean
19-
topOffset?: number
2017
}
2118

22-
const DEFAULT_TOP_OFFSET = 'calc(6.75rem + 1px)'
23-
const SMALLER_TOP_OFFSET = 'calc(6.75rem + 6px)'
24-
25-
export function AssistantPanel({ smaller = false, topOffset: measuredTopOffset, onClose }: AssistantPanelProps) {
19+
export function AssistantPanel({ onClose }: AssistantPanelProps) {
2620
const { data } = useQoveryStatus()
2721
const { showChat } = useSupportChat()
2822
const docLinks = useContextualDocLinks()
@@ -44,45 +38,31 @@ export function AssistantPanel({ smaller = false, topOffset: measuredTopOffset,
4438
return () => document.removeEventListener('keydown', down)
4539
}, [onClose])
4640

47-
const portalTarget = typeof document !== 'undefined' ? document.body : null
48-
49-
const hasMeasuredTopOffset = measuredTopOffset !== undefined
50-
const topOffset = hasMeasuredTopOffset ? `${measuredTopOffset}px` : smaller ? SMALLER_TOP_OFFSET : DEFAULT_TOP_OFFSET
51-
const panelHeight = `calc(100dvh - ${topOffset})`
52-
53-
if (!portalTarget) {
54-
return null
55-
}
41+
const transition = shouldReduceMotion
42+
? { duration: 0 }
43+
: {
44+
x: {
45+
type: 'spring' as const,
46+
stiffness: 900,
47+
// Critically damped (2 * sqrt(stiffness * mass) = ~42.4) to avoid visible overshoot/jitter.
48+
damping: 45,
49+
mass: 0.5,
50+
},
51+
opacity: {
52+
duration: 0.12,
53+
},
54+
}
5655

57-
return createPortal(
56+
return (
5857
<motion.div
59-
initial={shouldReduceMotion ? false : { top: topOffset, height: panelHeight, x: 32, opacity: 0 }}
60-
animate={{ top: topOffset, height: panelHeight, x: 0, opacity: 1 }}
58+
initial={shouldReduceMotion ? false : { x: 32, opacity: 0 }}
59+
animate={{ x: 0, opacity: 1 }}
6160
exit={shouldReduceMotion ? { opacity: 0 } : { x: 32, opacity: 0 }}
62-
transition={
63-
shouldReduceMotion
64-
? { duration: 0 }
65-
: {
66-
top: {
67-
duration: hasMeasuredTopOffset ? 0 : 0.12,
68-
ease: [0.2, 0, 0, 1],
69-
},
70-
height: {
71-
duration: hasMeasuredTopOffset ? 0 : 0.12,
72-
ease: [0.2, 0, 0, 1],
73-
},
74-
x: {
75-
type: 'spring',
76-
stiffness: 900,
77-
damping: 40,
78-
mass: 0.5,
79-
},
80-
opacity: {
81-
duration: 0.12,
82-
},
83-
}
84-
}
85-
className="fixed right-0 z-overlay flex w-[368px] flex-col overflow-hidden border-l border-neutral bg-background shadow-sm will-change-[top,height,transform]"
61+
transition={transition}
62+
// backfaceVisibility hint forces a dedicated compositor layer so the sliding panel
63+
// doesn't trigger repaints on the sticky navbar behind it.
64+
style={{ backfaceVisibility: 'hidden' }}
65+
className="flex h-full w-[368px] flex-col overflow-hidden border-l border-neutral bg-background shadow-sm will-change-transform"
8666
>
8767
<div className="flex justify-between px-5 pt-5">
8868
<div className="flex gap-3 font-bold">
@@ -183,8 +163,7 @@ export function AssistantPanel({ smaller = false, topOffset: measuredTopOffset,
183163
<div className="h-10"></div>
184164
)}
185165
</div>
186-
</motion.div>,
187-
portalTarget
166+
</motion.div>
188167
)
189168
}
190169

0 commit comments

Comments
 (0)