mirror of
https://github.com/codeflash-ai/codeflash-internal.git
synced 2026-05-04 18:25:18 +00:00
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:
parent
bf826a6e0f
commit
496033539e
2 changed files with 101 additions and 96 deletions
|
|
@ -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>
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
Loading…
Reference in a new issue