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.
This commit is contained in:
Daniel Han 2026-07-29 09:17:24 +00:00
commit 852ace5622
2 changed files with 169 additions and 61 deletions

View file

@ -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<HTMLDivElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const dragSessionRef = useRef<DragSession | null>(null);
const hasDraggedRef = useRef(false);
const preferredWidthRef = useRef<number | null>(null);
const preferredHeightRef = useRef<number | null>(null);
const surfaceWidthRef = useRef(0);
const remeasureRef = useRef(0);
const [layout, setLayout] = useState<MonitorLayout | null>(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<HTMLDivElement>) {
@ -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<HTMLDivElement | null>(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
}
>
<div className="mb-2 flex items-center justify-between gap-2 border-b border-border/60 pb-2">
@ -330,68 +429,76 @@ function FloatingMonitorPanel({
</div>
</div>
<div className="space-y-3 overflow-hidden">
<div className="space-y-1">
<div className="flex justify-between text-ui-11 font-medium font-mono">
<span>{t("settings.resources.liveMonitor.ram")}</span>
<span className={cn("tabular-nums", usageTextClass(ramPercent))}>
{Math.round(ramPercent)}%
</span>
</div>
<div className="text-xs text-muted-foreground font-mono tabular-nums">
{formatGiB(ramUsed)} / {formatGiB(ramTotal)}
</div>
<Progress
value={ramPercent}
className="mt-1 h-1.5 rounded-full bg-muted"
indicatorClassName={usageIndicatorClass(ramPercent)}
/>
</div>
{hasGpu && (
<div ref={scrollRef} className="min-h-0 flex-1 overflow-y-auto">
<div
ref={contentRef}
data-testid="floating-monitor-content"
className="space-y-3"
>
<div className="space-y-1">
<div className="flex justify-between text-ui-11 font-medium font-mono">
<span className="truncate flex-1 pr-2">
{t("settings.resources.liveMonitor.vram")}{" "}
{devices.length > 1
? `(${devices.length} GPUs)`
: `(${devices[0].name ?? "GPU"})`}
</span>
<span>{t("settings.resources.liveMonitor.ram")}</span>
<span
className={cn(
"shrink-0 tabular-nums",
vramUsageKnown
? usageTextClass(vramPercent)
: "text-muted-foreground",
)}
className={cn("tabular-nums", usageTextClass(ramPercent))}
>
{vramUsageKnown ? `${Math.round(vramPercent)}%` : "--"}
{Math.round(ramPercent)}%
</span>
</div>
<div className="text-xs text-muted-foreground font-mono tabular-nums">
{vramUsageKnown ? formatGiB(vramUsed) : unknownLabel} /{" "}
{formatGiB(vramTotal)}
{formatGiB(ramUsed)} / {formatGiB(ramTotal)}
</div>
<Progress
value={vramUsageKnown ? vramPercent : 0}
value={ramPercent}
className="mt-1 h-1.5 rounded-full bg-muted"
indicatorClassName={usageIndicatorClass(vramPercent)}
indicatorClassName={usageIndicatorClass(ramPercent)}
/>
</div>
)}
{separateInferenceGpu && (
<div className="flex justify-between gap-2 text-ui-11 font-mono">
<span className="text-muted-foreground">GGUF inference</span>
<span className="uppercase text-foreground">
{separateInferenceGpu.backend ?? "GPU"}
{separateInferenceGpu.available
? inferenceVramTotal
? ` · ${formatGiB(inferenceVramTotal)}`
: ""
: " · unavailable"}
</span>
</div>
)}
{hasGpu && (
<div className="space-y-1">
<div className="flex justify-between text-ui-11 font-medium font-mono">
<span className="truncate flex-1 pr-2">
{t("settings.resources.liveMonitor.vram")}{" "}
{devices.length > 1
? `(${devices.length} GPUs)`
: `(${devices[0].name ?? "GPU"})`}
</span>
<span
className={cn(
"shrink-0 tabular-nums",
vramUsageKnown
? usageTextClass(vramPercent)
: "text-muted-foreground",
)}
>
{vramUsageKnown ? `${Math.round(vramPercent)}%` : "--"}
</span>
</div>
<div className="text-xs text-muted-foreground font-mono tabular-nums">
{vramUsageKnown ? formatGiB(vramUsed) : unknownLabel} /{" "}
{formatGiB(vramTotal)}
</div>
<Progress
value={vramUsageKnown ? vramPercent : 0}
className="mt-1 h-1.5 rounded-full bg-muted"
indicatorClassName={usageIndicatorClass(vramPercent)}
/>
</div>
)}
{separateInferenceGpu && (
<div className="flex justify-between gap-2 text-ui-11 font-mono">
<span className="text-muted-foreground">GGUF inference</span>
<span className="uppercase text-foreground">
{separateInferenceGpu.backend ?? "GPU"}
{separateInferenceGpu.available
? inferenceVramTotal
? ` · ${formatGiB(inferenceVramTotal)}`
: ""
: " · unavailable"}
</span>
</div>
)}
</div>
</div>
</motion.div>
</div>

View file

@ -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";