feat(dashboard): add containedHeight prop for flexible layout adjustments

- Introduced a new `containedHeight` prop in `PageLayout` to manage height behavior.
- Updated `SidebarLayout` and `PageClient` components to utilize `containedHeight` for improved layout control.
- Enhanced styling to ensure proper flex behavior based on the new prop, allowing for better content overflow management.
This commit is contained in:
mantrakp04 2026-06-01 17:41:38 -07:00
parent 22e17c9dd4
commit d5c3ae3f30
4 changed files with 39 additions and 15 deletions

View File

@ -9,6 +9,7 @@ export function PageLayout(props: {
fillWidth?: boolean,
noPadding?: boolean,
allowContentOverflow?: boolean,
containedHeight?: boolean,
fullBleed?: boolean,
wrapHeaderInCard?: boolean,
} & ({
@ -19,6 +20,7 @@ export function PageLayout(props: {
return (
<div
className={cn("flex flex-1 min-h-0 flex-col", !props.noPadding && "py-4 px-4 sm:py-6 sm:px-6")}
data-contained-height={props.containedHeight ? "true" : undefined}
data-full-bleed={props.fullBleed ? "true" : undefined}
>
<div

View File

@ -45,6 +45,8 @@ const BACKGROUND_CHUNK_BATCH = 50;
const EXTRA_TABS_TO_SHOW = 2;
const REPLAY_SETTINGS_STORAGE_KEY = "stack.session-replay.settings";
const LEGACY_PLAYER_SPEED_STORAGE_KEY = "stack.session-replay.speed";
// TEMPORARY: multiply the rendered list for scroll verification. Set back to 1 after manual verification.
const SESSION_REPLAY_LIST_DEBUG_MULTIPLIER: number = 100;
type RrwebEventWithTime = import("rrweb/typings/types").eventWithTime;
type RrwebReplayer = InstanceType<typeof import("rrweb").Replayer>;
@ -62,6 +64,11 @@ type RecordingRow = {
eventCount: number,
};
type RecordingListRow = {
replay: RecordingRow,
key: string,
};
type ChunkRow = {
id: string,
batchId: string,
@ -505,6 +512,19 @@ export default function PageClient({ initialReplayId, lockedUserId }: PageClient
?? (standaloneReplay?.id === selectedRecordingId ? standaloneReplay : null),
[recordings, selectedRecordingId, standaloneReplay],
);
const recordingListRows = useMemo<RecordingListRow[]>(() => {
if (SESSION_REPLAY_LIST_DEBUG_MULTIPLIER === 1) {
return recordings.map((replay) => ({ replay, key: replay.id }));
}
const rows: RecordingListRow[] = [];
for (let copyIndex = 0; copyIndex < SESSION_REPLAY_LIST_DEBUG_MULTIPLIER; copyIndex++) {
for (const replay of recordings) {
rows.push({ replay, key: `${replay.id}:${copyIndex}` });
}
}
return rows;
}, [recordings]);
const hasAutoSelectedRef = useRef(false);
const loadingMoreRef = useRef(false);
@ -1464,17 +1484,18 @@ export default function PageClient({ initialReplayId, lockedUserId }: PageClient
) : undefined}
fillWidth
noPadding={isEmbedded}
containedHeight
>
<SessionReplayLimitBanner />
<PanelGroup data-walkthrough="analytics-replays" direction="horizontal" className="flex-1 min-h-[520px] rounded-xl border border-border/40 overflow-hidden bg-background">
{!isStandaloneReplayPage && (
<>
<Panel defaultSize={25} minSize={16}>
<div className="h-full flex flex-col">
<Panel defaultSize={25} minSize={16} className="min-h-0">
<div className="h-full min-h-0 flex flex-col overflow-hidden">
<div className="shrink-0 px-3 py-2 border-b border-border/30 space-y-2">
<div className="flex items-center justify-between gap-2 h-8">
<Typography className="text-sm font-medium">
Sessions{!loadingInitial && recordings.length > 0 ? ` (${recordings.length}${nextCursor ? "+" : ""})` : ""}
Sessions{!loadingInitial && recordingListRows.length > 0 ? ` (${recordingListRows.length}${nextCursor ? "+" : ""})` : ""}
</Typography>
<DropdownMenu>
<DropdownMenuTrigger asChild>
@ -1750,7 +1771,7 @@ export default function PageClient({ initialReplayId, lockedUserId }: PageClient
<div
ref={listBoxRef}
onScroll={onListScroll}
className="flex-1 overflow-y-auto"
className="flex-1 min-h-0 overflow-y-auto"
>
{loadingInitial ? (
<div className="p-2 space-y-1">
@ -1769,13 +1790,13 @@ export default function PageClient({ initialReplayId, lockedUserId }: PageClient
</div>
) : (
<div className="p-1.5 space-y-0.5">
{recordings.map((r) => {
{recordingListRows.map(({ replay: r, key }) => {
const isSelected = r.id === selectedRecordingId;
const durationMs = r.lastEventAt.getTime() - r.startedAt.getTime();
const duration = formatDurationMs(durationMs);
return (
<div
key={r.id}
key={key}
className={cn(
"rounded-lg",
isSelected ? "bg-muted/60 ring-1 ring-border/40" : "hover:bg-muted/20",
@ -1826,8 +1847,8 @@ export default function PageClient({ initialReplayId, lockedUserId }: PageClient
</>
)}
<Panel defaultSize={isStandaloneReplayPage ? 100 : 75} minSize={35}>
<div className="h-full flex flex-col">
<Panel defaultSize={isStandaloneReplayPage ? 100 : 75} minSize={35} className="min-h-0">
<div className="h-full min-h-0 flex flex-col overflow-hidden">
{(standaloneReplayError || ms.downloadError || ms.playerError) && (
<div className="p-3 space-y-2">
{standaloneReplayError && <Alert variant="destructive">{standaloneReplayError}</Alert>}
@ -1881,10 +1902,10 @@ export default function PageClient({ initialReplayId, lockedUserId }: PageClient
</div>
{selectedRecordingId ? (
<div className="flex-1 overflow-hidden flex flex-col">
<div className="flex-1 overflow-hidden flex flex-col">
<div className="flex-1 min-h-0 overflow-hidden flex flex-col">
<div className="flex-1 min-h-0 overflow-hidden flex flex-col">
<div
className="flex-1 overflow-hidden grid grid-cols-[minmax(0,1fr)_260px] gap-px bg-border/40"
className="flex-1 min-h-0 overflow-hidden grid grid-cols-[minmax(0,1fr)_260px] gap-px bg-border/40"
style={{
gridTemplateColumns: showRightColumn ? "minmax(0, 1fr) 260px" : "minmax(0, 1fr) 0px",
gridTemplateRows: showRightColumn ? `repeat(${EXTRA_TABS_TO_SHOW}, auto) 1fr` : "1fr",

View File

@ -737,7 +737,7 @@ export default function SidebarLayout(props: { children?: React.ReactNode }) {
<SpotlightSearchWrapper projectId={projectId} />
{/* Body Layout (Left Sidebar + Content + Right Companion) */}
<div className="relative flex flex-1 items-start w-full">
<div className="relative flex flex-1 items-start w-full has-[[data-contained-height]]:min-h-0 has-[[data-contained-height]]:items-stretch">
{/* Left Sidebar - Sticky */}
<aside
className={cn(
@ -753,13 +753,15 @@ export default function SidebarLayout(props: { children?: React.ReactNode }) {
</aside>
{/* Main Content Area */}
<main className="flex-1 min-w-0 pt-1 pb-3 px-3 lg:pl-0 dark:py-0 dark:px-2 dark:pb-3">
<main className="flex-1 min-w-0 pt-1 pb-3 px-3 lg:pl-0 dark:py-0 dark:px-2 dark:pb-3 has-[[data-contained-height]]:flex has-[[data-contained-height]]:min-h-0 has-[[data-contained-height]]:flex-col">
<div className={cn(
"relative flex min-w-0 flex-col overflow-visible has-[[data-full-bleed]]:h-full",
// Light mode card styling
"min-h-[calc(100vh-4.5rem)] bg-white/80 backdrop-blur-xl shadow-[0_4px_24px_rgba(0,0,0,0.06),0_1px_4px_rgba(0,0,0,0.04)] rounded-2xl border border-black/[0.06] lg:pr-20",
// Dark mode: remove card styling
"dark:bg-transparent dark:backdrop-blur-none dark:shadow-none dark:rounded-none dark:border-0 dark:lg:pr-20",
// Contained pages own their internal scroll regions, so the shell must pass down a finite flex height instead of sizing to content.
"has-[[data-contained-height]]:flex-1 has-[[data-contained-height]]:min-h-0 has-[[data-contained-height]]:overflow-hidden",
// Full-bleed pages (email editors etc.): remove card styling in light mode too (keep lg:pr-20 for companion space)
"has-[[data-full-bleed]]:min-h-0 has-[[data-full-bleed]]:bg-transparent has-[[data-full-bleed]]:backdrop-blur-none has-[[data-full-bleed]]:shadow-none has-[[data-full-bleed]]:rounded-none has-[[data-full-bleed]]:border-0",
)}>

View File

@ -1,8 +1,7 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { cn, Spinner, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@hexclave/ui";
import { TooltipPortal } from "@radix-ui/react-tooltip";
import { cn, Spinner, Tooltip, TooltipContent, TooltipPortal, TooltipProvider, TooltipTrigger } from "@hexclave/ui";
import { runAsynchronouslyWithAlert } from "@hexclave/shared/dist/utils/promises";
import { useGlassmorphicDefault } from "./card";