fix: use flex column layout and SSR-safe localStorage for split pane

Replace sticky positioning + ResizeObserver height calc with a flex
column layout (h-screen container, flex-1 panel group) that reliably
fills the viewport. Drop useDefaultLayout hook (not SSR-safe) in favor
of manual localStorage persistence inside useEffect.
This commit is contained in:
Kevin Turcios 2026-02-15 03:31:12 -05:00
parent bf826a6e0f
commit 496033539e
2 changed files with 101 additions and 96 deletions

View file

@ -9,8 +9,8 @@ import {
ResizablePanelGroup,
ResizablePanel,
ResizableHandle,
useDefaultLayout,
} from "@/components/ui/resizable"
import type { Layout } from "react-resizable-panels"
import type { TimelineSection } from "./timeline-types"
interface TimelinePageViewProps {
@ -32,12 +32,20 @@ export const TimelinePageView = memo(function TimelinePageView({
const [chatOpen, setChatOpen] = useState(false)
const sectionRefs = useRef<(HTMLDivElement | null)[]>([])
const refCallbacks = useRef(new Map<number, (el: HTMLDivElement | null) => void>())
const headerRef = useRef<HTMLDivElement>(null)
const [headerHeight, setHeaderHeight] = useState(0)
const scrollContainerRef = useRef<HTMLDivElement>(null)
const { defaultLayout, onLayoutChanged } = useDefaultLayout({
id: "obs-chat",
})
const [savedLayout, setSavedLayout] = useState<Layout | undefined>(undefined)
useEffect(() => {
try {
const stored = localStorage.getItem("obs-chat-layout")
if (stored) setSavedLayout(JSON.parse(stored))
} catch { /* ignore */ }
}, [])
const handleLayoutChanged = useCallback((layout: Layout) => {
try { localStorage.setItem("obs-chat-layout", JSON.stringify(layout)) } catch { /* ignore */ }
}, [])
function getSectionRef(index: number) {
let cb = refCallbacks.current.get(index)
@ -49,19 +57,10 @@ export const TimelinePageView = memo(function TimelinePageView({
}
useEffect(() => {
const header = headerRef.current
if (!header) return
// When chat is open, the timeline scrolls inside a panel container
// rather than the viewport, so use that container as the observer root
const root = chatOpen ? scrollContainerRef.current : null
const ro = new ResizeObserver(([entry]) => {
setHeaderHeight(entry.contentRect.height + entry.target.getBoundingClientRect().top)
})
ro.observe(header)
setHeaderHeight(header.offsetHeight)
return () => ro.disconnect()
}, [])
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
let bestIndex = -1
@ -73,10 +72,11 @@ export const TimelinePageView = memo(function TimelinePageView({
const index = Number(entry.target.getAttribute("data-section-index"))
if (isNaN(index)) continue
// Find section closest to 35% from top of viewport
const rect = entry.boundingClientRect
const sectionMiddle = rect.top + rect.height / 2
const distance = Math.abs(sectionMiddle - window.innerHeight * 0.35)
const viewportH = root ? root.clientHeight : window.innerHeight
const rootTop = root ? root.getBoundingClientRect().top : 0
const distance = Math.abs((sectionMiddle - rootTop) - viewportH * 0.35)
if (distance < bestDistance) {
bestDistance = distance
@ -89,6 +89,7 @@ export const TimelinePageView = memo(function TimelinePageView({
}
},
{
root,
threshold: [0, 0.1, 0.25, 0.5, 0.75, 1.0],
rootMargin: "-10% 0px -55% 0px",
}
@ -102,7 +103,7 @@ export const TimelinePageView = memo(function TimelinePageView({
})
return () => observer.disconnect()
}, [sections.length])
}, [sections.length, chatOpen])
const toggleChat = useCallback(() => setChatOpen((prev) => !prev), [])
const closeChat = useCallback(() => setChatOpen(false), [])
@ -120,12 +121,8 @@ export const TimelinePageView = memo(function TimelinePageView({
activeSection?.content.type === "candidate" ||
activeSection?.content.type === "refinement"
const timelineContent = (
<div
className={`mx-auto px-4 pb-20 relative ${
shouldExpandContainer ? "max-w-full" : "max-w-6xl"
}`}
>
const timelineSections = (
<>
<div className="sticky top-1/2 -translate-y-1/2 z-20 pointer-events-none h-0">
<div className="absolute right-4 -top-2">
<div className="w-4 h-4 rounded-full bg-zinc-400 dark:bg-zinc-500 border-2 border-white dark:border-zinc-900" />
@ -156,80 +153,94 @@ export const TimelinePageView = memo(function TimelinePageView({
</span>
</div>
</div>
</>
)
const header = (
<div className={`${chatOpen ? "flex-shrink-0" : "sticky top-0"} z-30 bg-white/80 dark:bg-zinc-900/80 backdrop-blur-sm border-b border-zinc-200 dark:border-zinc-700`}>
<div className="max-w-6xl mx-auto px-4 py-3">
<div className="flex items-center justify-between mb-2">
<div>
<h2 className="text-sm font-medium text-zinc-900 dark:text-white">
Optimization Timeline
</h2>
{functionName && (
<p className="text-xs text-zinc-500 dark:text-zinc-400">
{functionName}
{filePath && <span className="ml-1 opacity-60">in {filePath}</span>}
</p>
)}
</div>
<div className="flex items-center gap-3">
{traceId && (
<button
onClick={toggleChat}
className={`flex items-center gap-1.5 px-2.5 py-1.5 text-xs rounded-md transition-colors ${
chatOpen
? "bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300"
: "text-zinc-600 dark:text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-700"
}`}
title="Chat with this trace"
>
<MessageSquare className="h-3.5 w-3.5" />
<span>Chat</span>
</button>
)}
<span className="text-xs text-zinc-500 dark:text-zinc-400">
{activeIndex + 1} of {sections.length} {"\u00b7"} {formatTime(totalDuration)}
</span>
</div>
</div>
<div className="h-1 bg-zinc-200 dark:bg-zinc-700 rounded-full overflow-hidden">
<div
className="h-full bg-zinc-400 dark:bg-zinc-500 transition-all duration-200"
style={{ width: `${((activeIndex + 1) / sections.length) * 100}%` }}
/>
</div>
</div>
</div>
)
return (
<div className="relative">
<div
ref={headerRef}
className="sticky top-0 z-30 bg-white/80 dark:bg-zinc-900/80 backdrop-blur-sm border-b border-zinc-200 dark:border-zinc-700 mb-6"
>
<div className="max-w-6xl mx-auto px-4 py-3">
<div className="flex items-center justify-between mb-2">
<div>
<h2 className="text-sm font-medium text-zinc-900 dark:text-white">
Optimization Timeline
</h2>
{functionName && (
<p className="text-xs text-zinc-500 dark:text-zinc-400">
{functionName}
{filePath && <span className="ml-1 opacity-60">in {filePath}</span>}
</p>
)}
</div>
<div className="flex items-center gap-3">
{traceId && (
<button
onClick={toggleChat}
className={`flex items-center gap-1.5 px-2.5 py-1.5 text-xs rounded-md transition-colors ${
chatOpen
? "bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300"
: "text-zinc-600 dark:text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-700"
}`}
title="Chat with this trace"
>
<MessageSquare className="h-3.5 w-3.5" />
<span>Chat</span>
</button>
)}
<span className="text-xs text-zinc-500 dark:text-zinc-400">
{activeIndex + 1} of {sections.length} {"\u00b7"} {formatTime(totalDuration)}
</span>
</div>
</div>
<div className="h-1 bg-zinc-200 dark:bg-zinc-700 rounded-full overflow-hidden">
<div
className="h-full bg-zinc-400 dark:bg-zinc-500 transition-all duration-200"
style={{ width: `${((activeIndex + 1) / sections.length) * 100}%` }}
/>
</div>
</div>
</div>
{chatOpen && traceId ? (
if (chatOpen && traceId) {
return (
<div className="flex flex-col h-screen">
{header}
<ResizablePanelGroup
orientation="horizontal"
defaultLayout={defaultLayout}
onLayoutChanged={onLayoutChanged}
defaultLayout={savedLayout}
onLayoutChanged={handleLayoutChanged}
className="flex-1 min-h-0"
>
<ResizablePanel id="timeline" defaultSize="65%" minSize="30%">
{timelineContent}
<div
ref={scrollContainerRef}
className={`h-full overflow-y-auto px-4 pb-20 mx-auto ${
shouldExpandContainer ? "max-w-full" : "max-w-6xl"
}`}
>
{timelineSections}
</div>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel id="chat" defaultSize="35%" minSize="20%" maxSize="50%">
<div
className="sticky h-screen overflow-hidden"
style={{ top: `${headerHeight}px`, height: `calc(100vh - ${headerHeight}px)` }}
>
<TimelineChat traceId={traceId} onClose={closeChat} />
</div>
<TimelineChat traceId={traceId} onClose={closeChat} />
</ResizablePanel>
</ResizablePanelGroup>
) : (
timelineContent
)}
</div>
)
}
return (
<div className="relative">
{header}
<div
className={`mx-auto px-4 pb-20 relative mt-6 ${
shouldExpandContainer ? "max-w-full" : "max-w-6xl"
}`}
>
{timelineSections}
</div>
</div>
)
})

View file

@ -5,7 +5,6 @@ import {
Group,
Panel,
Separator,
useDefaultLayout,
type GroupProps,
type SeparatorProps,
} from "react-resizable-panels"
@ -49,9 +48,4 @@ const ResizableHandle = ({
</Separator>
)
export {
ResizablePanelGroup,
ResizablePanel,
ResizableHandle,
useDefaultLayout,
}
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }