From 852ace56227b4633c72ce33bbc75fc9cb23df0ac Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Wed, 29 Jul 2026 09:17:24 +0000 Subject: [PATCH] Fix floating monitor content growth for PR #7579 Delayed GPU rows arriving after the first /api/system response were clipped or briefly pushed outside the inset, because min-height: min-content and the computed max-height sat on the same element the ResizeObserver watched. Chromium and WebKit let min-content win, so the panel grew past the cap and was corrected a frame later (the CI failure: content growth bottom inset expected 884, got 932). Firefox lets max-height win, so the panel never resized, the observer never fired and the cap was never lifted: the VRAM rows stayed hidden for good. - Move the rows into a flex-1 min-h-0 overflow-y-auto region and observe the unclamped content wrapper, so growth is seen even while capped. - Derive the wanted height from chrome + content instead of the rendered box, so top moves up in the same pass and nothing overshoots. - Replace min-height: min-content with a resolved number clamped to maxHeight, keeping the initial size as the minimum. - Re-measure the natural width when the surface resizes, deferred to rAF, so a monitor first opened in a narrow window widens again and Firefox does not report an observer loop. - Point the growth probe at the content region, where a real row renders. Verified on Chromium, Chrome, Firefox and WebKit across [Windows, Linux, WSL, macOS] x [NVIDIA, AMD, CPU] payloads. --- .../src/components/floating-monitor.tsx | 227 +++++++++++++----- tests/studio/playwright_chat_ui.py | 3 +- 2 files changed, 169 insertions(+), 61 deletions(-) diff --git a/studio/frontend/src/components/floating-monitor.tsx b/studio/frontend/src/components/floating-monitor.tsx index e01226a94f..bdf7a8fda0 100644 --- a/studio/frontend/src/components/floating-monitor.tsx +++ b/studio/frontend/src/components/floating-monitor.tsx @@ -21,6 +21,7 @@ interface MonitorLayout { left: number; top: number; minWidth: number; + minHeight: number; maxWidth: number; maxHeight: number; } @@ -41,10 +42,43 @@ function clamp(value: number, min: number, max: number): number { return Math.max(min, Math.min(max, value)); } +// Height the panel wants. Reading the rendered box instead hides growth once +// maxHeight caps it, so the observer never fires and the cap is never lifted. +function desiredPanelHeight( + renderedHeight: number, + scroll: HTMLDivElement | null, + content: HTMLDivElement | null, +): number { + if (!(scroll && content)) { + return renderedHeight; + } + // The scroll region is the only flexible child, so the rest is fixed chrome. + const chrome = renderedHeight - scroll.getBoundingClientRect().height; + return chrome + content.getBoundingClientRect().height; +} + +// Width the panel wants. While anchored, maxWidth equals the current width, so +// the cap is also a floor: a monitor opened in a narrow window never widens +// again. Lift the cap for one measurement to break that. +function naturalWidth(monitor: HTMLDivElement): number { + const capped = monitor.style.maxWidth; + // "none", not "", so the class-level max-w-full lifts too. + monitor.style.maxWidth = "none"; + const width = monitor.getBoundingClientRect().width; + monitor.style.maxWidth = capped; + return width; +} + function useMonitorLayout(constraintsElement: HTMLDivElement | null) { const monitorRef = useRef(null); + const scrollRef = useRef(null); + const contentRef = useRef(null); const dragSessionRef = useRef(null); const hasDraggedRef = useRef(false); + const preferredWidthRef = useRef(null); + const preferredHeightRef = useRef(null); + const surfaceWidthRef = useRef(0); + const remeasureRef = useRef(0); const [layout, setLayout] = useState(null); useLayoutEffect(() => { @@ -53,11 +87,47 @@ function useMonitorLayout(constraintsElement: HTMLDivElement | null) { if (!(monitor && constraints)) { return; } + // Deferred to the next frame: writing a style while ResizeObserver entries + // are delivered makes Firefox report an observer loop. A hand-resized panel + // keeps the user's width instead of re-measuring. + const scheduleWidthRemeasure = (surfaceWidth: number) => { + if (surfaceWidth === surfaceWidthRef.current) { + return; + } + surfaceWidthRef.current = surfaceWidth; + if (monitor.style.width || remeasureRef.current) { + return; + } + remeasureRef.current = requestAnimationFrame(() => { + remeasureRef.current = 0; + preferredWidthRef.current = naturalWidth(monitor); + reconcileGeometry(); + }); + }; + const reconcileGeometry = () => { const constraintsBox = constraints.getBoundingClientRect(); const monitorBox = monitor.getBoundingClientRect(); - const width = Math.min(monitorBox.width, constraintsBox.width); - const height = Math.min(monitorBox.height, constraintsBox.height); + const desiredHeight = desiredPanelHeight( + monitorBox.height, + scrollRef.current, + contentRef.current, + ); + + scheduleWidthRemeasure(constraintsBox.width); + const desiredWidth = Math.max( + monitorBox.width, + preferredWidthRef.current ?? monitorBox.width, + ); + + // Content height is the floor, as a resolved number: intrinsic + // min-content outranks max-height, a number clamped to it cannot. + if (!monitor.style.height) { + preferredHeightRef.current = desiredHeight; + } + + const width = Math.min(desiredWidth, constraintsBox.width); + const height = Math.min(desiredHeight, constraintsBox.height); const maxLeft = Math.max(0, constraintsBox.width - width); const maxTop = Math.max(0, constraintsBox.height - height); const currentLeft = monitorBox.left - constraintsBox.left; @@ -81,13 +151,16 @@ function useMonitorLayout(constraintsElement: HTMLDivElement | null) { const next = { left, top, - minWidth: current?.minWidth ?? monitorBox.width, + minWidth: preferredWidthRef.current ?? monitorBox.width, + minHeight: preferredHeightRef.current ?? monitorBox.height, maxWidth: constraintsBox.width - left, maxHeight: constraintsBox.height - top, }; return current && current.left === next.left && current.top === next.top && + current.minWidth === next.minWidth && + current.minHeight === next.minHeight && current.maxWidth === next.maxWidth && current.maxHeight === next.maxHeight ? current @@ -99,7 +172,18 @@ function useMonitorLayout(constraintsElement: HTMLDivElement | null) { const observer = new ResizeObserver(reconcileGeometry); observer.observe(constraints); observer.observe(monitor); - return () => observer.disconnect(); + // The unclamped content wrapper is what makes late GPU rows reposition the + // panel instead of being cut off. + if (contentRef.current) { + observer.observe(contentRef.current); + } + return () => { + observer.disconnect(); + if (remeasureRef.current) { + cancelAnimationFrame(remeasureRef.current); + remeasureRef.current = 0; + } + }; }, [constraintsElement]); function startDrag(event: PointerEvent) { @@ -186,7 +270,15 @@ function useMonitorLayout(constraintsElement: HTMLDivElement | null) { } } - return { monitorRef, layout, startDrag, updateDrag, finishDrag }; + return { + monitorRef, + scrollRef, + contentRef, + layout, + startDrag, + updateDrag, + finishDrag, + }; } function clampPercent(value: number): number { @@ -232,8 +324,15 @@ function FloatingMonitorPanel({ const t = useT(); const [constraintsElement, setConstraintsElement] = useState(null); - const { monitorRef, layout, startDrag, updateDrag, finishDrag } = - useMonitorLayout(constraintsElement); + const { + monitorRef, + scrollRef, + contentRef, + layout, + startDrag, + updateDrag, + finishDrag, + } = useMonitorLayout(constraintsElement); const ramTotal = systemInfo.memory?.total_gb ?? 0; const ramAvailable = systemInfo.memory?.available_gb ?? 0; @@ -280,7 +379,7 @@ function FloatingMonitorPanel({ animate={{ opacity: 1 }} exit={{ opacity: 0 }} className={cn( - "settings-surface pointer-events-auto absolute max-h-full w-64 max-w-full cursor-default select-none overflow-hidden rounded-xl border border-border/70 p-3 shadow-border ring-0 backdrop-blur-sm", + "settings-surface pointer-events-auto absolute flex max-h-full w-64 max-w-full cursor-default select-none flex-col overflow-hidden rounded-xl border border-border/70 p-3 shadow-border ring-0 backdrop-blur-sm", layout ? "top-0 left-0 resize" : "right-0 bottom-0", )} data-testid="floating-monitor" @@ -290,11 +389,11 @@ function FloatingMonitorPanel({ left: layout.left, top: layout.top, minWidth: Math.min(layout.minWidth, layout.maxWidth), - minHeight: "min-content", + minHeight: Math.min(layout.minHeight, layout.maxHeight), maxWidth: layout.maxWidth, maxHeight: layout.maxHeight, } - : { minHeight: "min-content" } + : undefined } >
@@ -330,68 +429,76 @@ function FloatingMonitorPanel({
-
-
-
- {t("settings.resources.liveMonitor.ram")} - - {Math.round(ramPercent)}% - -
-
- {formatGiB(ramUsed)} / {formatGiB(ramTotal)} -
- -
- - {hasGpu && ( +
+
- - {t("settings.resources.liveMonitor.vram")}{" "} - {devices.length > 1 - ? `(${devices.length} GPUs)` - : `(${devices[0].name ?? "GPU"})`} - + {t("settings.resources.liveMonitor.ram")} - {vramUsageKnown ? `${Math.round(vramPercent)}%` : "--"} + {Math.round(ramPercent)}%
- {vramUsageKnown ? formatGiB(vramUsed) : unknownLabel} /{" "} - {formatGiB(vramTotal)} + {formatGiB(ramUsed)} / {formatGiB(ramTotal)}
- )} - {separateInferenceGpu && ( -
- GGUF inference - - {separateInferenceGpu.backend ?? "GPU"} - {separateInferenceGpu.available - ? inferenceVramTotal - ? ` · ${formatGiB(inferenceVramTotal)}` - : "" - : " · unavailable"} - -
- )} + + {hasGpu && ( +
+
+ + {t("settings.resources.liveMonitor.vram")}{" "} + {devices.length > 1 + ? `(${devices.length} GPUs)` + : `(${devices[0].name ?? "GPU"})`} + + + {vramUsageKnown ? `${Math.round(vramPercent)}%` : "--"} + +
+
+ {vramUsageKnown ? formatGiB(vramUsed) : unknownLabel} /{" "} + {formatGiB(vramTotal)} +
+ +
+ )} + {separateInferenceGpu && ( +
+ GGUF inference + + {separateInferenceGpu.backend ?? "GPU"} + {separateInferenceGpu.available + ? inferenceVramTotal + ? ` · ${formatGiB(inferenceVramTotal)}` + : "" + : " · unavailable"} + +
+ )} +
diff --git a/tests/studio/playwright_chat_ui.py b/tests/studio/playwright_chat_ui.py index 2019959711..d95fea0abd 100644 --- a/tests/studio/playwright_chat_ui.py +++ b/tests/studio/playwright_chat_ui.py @@ -348,7 +348,8 @@ def exercise_floating_monitor_geometry(page): ) # Delayed GPU rows must expand upward and retain the initial bottom anchor. - monitor.evaluate( + # The probe goes in the content region, where a real row is rendered. + monitor.get_by_test_id("floating-monitor-content").evaluate( """node => { const probe = document.createElement("div"); probe.dataset.testid = "floating-monitor-growth-probe";