From e08d8634176c64ed6c962ea7f27ca85c7dd05fb3 Mon Sep 17 00:00:00 2001 From: oobabooga <112222186+oobabooga@users.noreply.github.com> Date: Tue, 28 Jul 2026 17:13:02 -0300 Subject: [PATCH 1/3] Studio: stabilize floating live monitor geometry --- .../src/components/floating-monitor.tsx | 475 +++++++++++++----- tests/studio/playwright_chat_ui.py | 202 ++++++++ 2 files changed, 542 insertions(+), 135 deletions(-) diff --git a/studio/frontend/src/components/floating-monitor.tsx b/studio/frontend/src/components/floating-monitor.tsx index d86b9ab9af..e01226a94f 100644 --- a/studio/frontend/src/components/floating-monitor.tsx +++ b/studio/frontend/src/components/floating-monitor.tsx @@ -4,18 +4,193 @@ import { Button } from "@/components/ui/button"; import { Progress } from "@/components/ui/progress"; import { useMonitorOverlayStore } from "@/features/settings"; -import { - aggregateGpuMemoryTotalGb, - useSystemInfo, -} from "@/hooks/use-system"; +import { aggregateGpuMemoryTotalGb, useSystemInfo } from "@/hooks/use-system"; import { useT } from "@/i18n"; import { cn } from "@/lib/utils"; import { CpuIcon, GripVerticalIcon, XIcon } from "lucide-react"; -import { AnimatePresence, motion, useDragControls } from "motion/react"; -import { type PointerEvent, useMemo, useState } from "react"; +import { AnimatePresence, motion } from "motion/react"; +import { + type PointerEvent, + useEffect, + useLayoutEffect, + useRef, + useState, +} from "react"; + +interface MonitorLayout { + left: number; + top: number; + minWidth: number; + maxWidth: number; + maxHeight: number; +} + +interface DragSession { + pointerId: number; + startX: number; + startY: number; + left: number; + top: number; + maxLeft: number; + maxTop: number; + constraintsWidth: number; + constraintsHeight: number; +} + +function clamp(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)); +} + +function useMonitorLayout(constraintsElement: HTMLDivElement | null) { + const monitorRef = useRef(null); + const dragSessionRef = useRef(null); + const hasDraggedRef = useRef(false); + const [layout, setLayout] = useState(null); + + useLayoutEffect(() => { + const monitor = monitorRef.current; + const constraints = constraintsElement; + if (!(monitor && constraints)) { + return; + } + 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 maxLeft = Math.max(0, constraintsBox.width - width); + const maxTop = Math.max(0, constraintsBox.height - height); + const currentLeft = monitorBox.left - constraintsBox.left; + const currentTop = monitorBox.top - constraintsBox.top; + const left = hasDraggedRef.current + ? clamp(currentLeft, 0, maxLeft) + : maxLeft; + const top = hasDraggedRef.current ? clamp(currentTop, 0, maxTop) : maxTop; + + const session = dragSessionRef.current; + if (session) { + session.left = left; + session.top = top; + session.maxLeft = maxLeft; + session.maxTop = maxTop; + session.constraintsWidth = constraintsBox.width; + session.constraintsHeight = constraintsBox.height; + } + + setLayout((current) => { + const next = { + left, + top, + minWidth: current?.minWidth ?? monitorBox.width, + maxWidth: constraintsBox.width - left, + maxHeight: constraintsBox.height - top, + }; + return current && + current.left === next.left && + current.top === next.top && + current.maxWidth === next.maxWidth && + current.maxHeight === next.maxHeight + ? current + : next; + }); + }; + + reconcileGeometry(); + const observer = new ResizeObserver(reconcileGeometry); + observer.observe(constraints); + observer.observe(monitor); + return () => observer.disconnect(); + }, [constraintsElement]); + + function startDrag(event: PointerEvent) { + const monitor = monitorRef.current; + if (event.button !== 0 || !(monitor && constraintsElement)) { + return; + } + + event.preventDefault(); + const constraintsBox = constraintsElement.getBoundingClientRect(); + const monitorBox = monitor.getBoundingClientRect(); + const left = monitorBox.left - constraintsBox.left; + const top = monitorBox.top - constraintsBox.top; + hasDraggedRef.current = true; + + // Native resize records attempted inline dimensions even when max-width + // or max-height hides them. Normalize only hidden dimensions so an + // auto-sized monitor can still grow when system rows arrive later. + const inlineWidth = Number.parseFloat(monitor.style.width); + const inlineHeight = Number.parseFloat(monitor.style.height); + if ( + Number.isFinite(inlineWidth) && + Math.abs(inlineWidth - monitorBox.width) > 0.5 + ) { + monitor.style.width = `${monitorBox.width}px`; + } + if ( + Number.isFinite(inlineHeight) && + Math.abs(inlineHeight - monitorBox.height) > 0.5 + ) { + monitor.style.height = `${monitorBox.height}px`; + } + + dragSessionRef.current = { + pointerId: event.pointerId, + startX: event.clientX, + startY: event.clientY, + left, + top, + maxLeft: Math.max(0, constraintsBox.width - monitorBox.width), + maxTop: Math.max(0, constraintsBox.height - monitorBox.height), + constraintsWidth: constraintsBox.width, + constraintsHeight: constraintsBox.height, + }; + event.currentTarget.setPointerCapture(event.pointerId); + } + + function updateDrag(event: PointerEvent) { + const session = dragSessionRef.current; + if (!session || session.pointerId !== event.pointerId) { + return; + } + + const left = clamp( + session.left + event.clientX - session.startX, + 0, + session.maxLeft, + ); + const top = clamp( + session.top + event.clientY - session.startY, + 0, + session.maxTop, + ); + session.startX = event.clientX; + session.startY = event.clientY; + session.left = left; + session.top = top; + setLayout((current) => + !current || (current.left === left && current.top === top) + ? current + : { + ...current, + left, + top, + maxWidth: session.constraintsWidth - left, + maxHeight: session.constraintsHeight - top, + }, + ); + } + + function finishDrag(event: PointerEvent) { + if (dragSessionRef.current?.pointerId === event.pointerId) { + dragSessionRef.current = null; + } + } + + return { monitorRef, layout, startDrag, updateDrag, finishDrag }; +} function clampPercent(value: number): number { - return Math.max(0, Math.min(100, value)); + return clamp(value, 0, 100); } function usageIndicatorClass(percent: number): string { @@ -45,23 +220,20 @@ function formatGiB(value: number): string { return `${value.toFixed(digits)} GiB`; } -export function FloatingMonitor() { - const t = useT(); - const { isOpen, setIsOpen } = useMonitorOverlayStore(); - const systemInfo = useSystemInfo({ enabled: isOpen, pollMs: 5000 }); +interface FloatingMonitorPanelProps { + onClose: () => void; + systemInfo: ReturnType; +} +function FloatingMonitorPanel({ + onClose, + systemInfo, +}: FloatingMonitorPanelProps) { + const t = useT(); const [constraintsElement, setConstraintsElement] = useState(null); - const constraintsRef = useMemo( - () => ({ current: constraintsElement }), - [constraintsElement], - ); - const dragControls = useDragControls(); - - function startDrag(event: PointerEvent) { - event.preventDefault(); - dragControls.start(event); - } + const { monitorRef, layout, startDrag, updateDrag, finishDrag } = + useMonitorLayout(constraintsElement); const ramTotal = systemInfo.memory?.total_gb ?? 0; const ramAvailable = systemInfo.memory?.available_gb ?? 0; @@ -98,124 +270,157 @@ export function FloatingMonitor() { const hasGpu = (displayedGpu?.available ?? false) && devices.length > 0; return ( - - {isOpen && ( -
- -
-
- - - {t("settings.resources.liveMonitor.title")} - -
-
-
- -
- - -
+
+ +
+
+ + + {t("settings.resources.liveMonitor.title")} + +
+
+
+
- -
-
- {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"})`} - - - {vramUsageKnown ? `${Math.round(vramPercent)}%` : "--"} - -
-
- {vramUsageKnown ? formatGiB(vramUsed) : unknownLabel} /{" "} - {formatGiB(vramTotal)} -
- -
- )} - {separateInferenceGpu && ( -
- GGUF inference - - {separateInferenceGpu.backend ?? "GPU"} - {separateInferenceGpu.available - ? inferenceVramTotal - ? ` · ${formatGiB(inferenceVramTotal)}` - : "" - : " · unavailable"} - -
- )} -
- + + +
+ +
+
+
+ {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"})`} + + + {vramUsageKnown ? `${Math.round(vramPercent)}%` : "--"} + +
+
+ {vramUsageKnown ? formatGiB(vramUsed) : unknownLabel} /{" "} + {formatGiB(vramTotal)} +
+ +
+ )} + {separateInferenceGpu && ( +
+ GGUF inference + + {separateInferenceGpu.backend ?? "GPU"} + {separateInferenceGpu.available + ? inferenceVramTotal + ? ` · ${formatGiB(inferenceVramTotal)}` + : "" + : " · unavailable"} + +
+ )} +
+
+
+ ); +} + +export function FloatingMonitor() { + const { isOpen, setIsOpen } = useMonitorOverlayStore(); + const systemInfo = useSystemInfo({ enabled: isOpen, pollMs: 5000 }); + const [panelKey, setPanelKey] = useState(0); + const wasOpenRef = useRef(isOpen); + + // Each visible panel owns native inline resize state. Advance the key on + // close so reopening during the exit animation still mounts fresh geometry. + useEffect(() => { + if (wasOpenRef.current && !isOpen) { + setPanelKey((current) => current + 1); + } + wasOpenRef.current = isOpen; + }, [isOpen]); + + return ( + + {isOpen && ( + setIsOpen(false)} + /> )} ); diff --git a/tests/studio/playwright_chat_ui.py b/tests/studio/playwright_chat_ui.py index b182e66f01..78a49a5f07 100644 --- a/tests/studio/playwright_chat_ui.py +++ b/tests/studio/playwright_chat_ui.py @@ -263,6 +263,205 @@ def parse_rgb(s): return tuple(int(x) for x in m.groups()) if m else None +def exercise_floating_monitor_geometry(page): + """Exercise content, drag, native resize, and viewport geometry.""" + monitor = page.get_by_test_id("floating-monitor") + monitor.wait_for(state = "visible", timeout = 10_000) + monitor_handle = page.get_by_test_id("floating-monitor-drag-handle") + viewport = page.viewport_size + if viewport is None: + fail("Playwright viewport unavailable for floating monitor check") + inset = 16 + tolerance = 1 + + def monitor_box(label): + box = monitor.bounding_box() + if box is None: + fail(f"floating monitor has no bounding box during {label}") + return box + + def wait_for_box(label, predicate): + deadline = time.time() + 5 + box = monitor_box(label) + while not predicate(box) and time.time() < deadline: + page.wait_for_timeout(50) + box = monitor_box(label) + if not predicate(box): + fail(f"floating monitor did not settle during {label}: {box!r}") + return box + + def pointer_drag(start_x, start_y, end_x, end_y): + page.mouse.move(start_x, start_y) + page.mouse.down() + page.mouse.move(end_x, end_y, steps = 10) + page.mouse.up() + page.wait_for_timeout(100) + + def drag_monitor_to(x, y): + box = monitor_handle.bounding_box() + if box is None: + fail("floating monitor handle has no bounding box") + pointer_drag( + box["x"] + box["width"] / 2, + box["y"] + box["height"] / 2, + x, + y, + ) + return monitor_box("drag") + + def resize_monitor_to(x, y, grip_inset = 8): + before = monitor_box("resize") + pointer_drag( + before["x"] + before["width"] - grip_inset, + before["y"] + before["height"] - grip_inset, + x, + y, + ) + return before, monitor_box("resize") + + def expect_close(actual, expected, label): + if abs(actual - expected) > tolerance: + fail(f"{label}: expected {expected!r}, got {actual!r}") + + def is_inside(box, surface): + return ( + box["x"] >= inset - tolerance + and box["y"] >= inset - tolerance + and box["x"] + box["width"] + <= surface["width"] - inset + tolerance + and box["y"] + box["height"] + <= surface["height"] - inset + tolerance + ) + + initial_box = monitor_box("initial placement") + expect_close( + initial_box["x"] + initial_box["width"], + viewport["width"] - inset, + "initial right inset", + ) + expect_close( + initial_box["y"] + initial_box["height"], + viewport["height"] - inset, + "initial bottom inset", + ) + + # Delayed GPU rows must expand upward and retain the initial bottom anchor. + monitor.evaluate( + """node => { + const probe = document.createElement("div"); + probe.dataset.testid = "floating-monitor-growth-probe"; + probe.style.height = "48px"; + node.appendChild(probe); + }""" + ) + grown_box = wait_for_box( + "content growth", + lambda box: box["height"] >= initial_box["height"] + 47, + ) + expect_close( + grown_box["y"] + grown_box["height"], + viewport["height"] - inset, + "content growth bottom inset", + ) + monitor.get_by_test_id("floating-monitor-growth-probe").evaluate( + "node => node.remove()" + ) + initial_box = wait_for_box( + "content shrink", + lambda box: ( + abs(box["height"] - initial_box["height"]) <= tolerance + and abs( + box["y"] + box["height"] - viewport["height"] + inset + ) <= tolerance + ), + ) + + # Chromium retains a blocked inline resize request. A subsequent drag must + # not reveal that hidden size. + _, blocked_box = resize_monitor_to( + viewport["width"] - 2, + viewport["height"] - 2, + ) + expect_close(blocked_box["width"], initial_box["width"], "blocked width") + expect_close(blocked_box["height"], initial_box["height"], "blocked height") + left_box = drag_monitor_to(0, viewport["height"] / 2) + expect_close(left_box["x"], inset, "left inset") + expect_close(left_box["width"], initial_box["width"], "post-drag width") + expect_close(left_box["height"], initial_box["height"], "post-drag height") + right_box = drag_monitor_to(viewport["width"], viewport["height"] / 2) + expect_close( + right_box["x"] + right_box["width"], + viewport["width"] - inset, + "right inset", + ) + + # Constraint changes during pointer capture must rebase the active drag. + handle_box = monitor_handle.bounding_box() + if handle_box is None: + fail("floating monitor handle has no active-drag bounding box") + page.mouse.move( + handle_box["x"] + handle_box["width"] / 2, + handle_box["y"] + handle_box["height"] / 2, + ) + page.mouse.down() + reduced_viewport = {"width": 500, "height": 400} + page.set_viewport_size(reduced_viewport) + page.mouse.move(498, 398, steps = 10) + page.mouse.up() + wait_for_box( + "active viewport shrink", + lambda box: is_inside(box, reduced_viewport), + ) + + narrow_viewport = {"width": 260, "height": 400} + page.set_viewport_size(narrow_viewport) + wait_for_box("narrow viewport", lambda box: is_inside(box, narrow_viewport)) + page.set_viewport_size(viewport) + wait_for_box("viewport restore", lambda box: is_inside(box, viewport)) + + resize_start = drag_monitor_to(0, 0) + _, resized_box = resize_monitor_to( + resize_start["x"] + resize_start["width"] - 8 + 40, + resize_start["y"] + resize_start["height"] - 8 + 30, + ) + expect_close(resized_box["width"], resize_start["width"] + 40, "resize width") + expect_close(resized_box["height"], resize_start["height"] + 30, "resize height") + expect_close(resized_box["x"], resize_start["x"], "resize left edge") + expect_close(resized_box["y"], resize_start["y"], "resize top edge") + + _, minimum_box = resize_monitor_to( + resized_box["x"] + resized_box["width"] - 102, + resized_box["y"] + resized_box["height"] - 102, + grip_inset = 2, + ) + expect_close(minimum_box["width"], resize_start["width"], "minimum width") + expect_close(minimum_box["height"], resize_start["height"], "minimum height") + + drag_monitor_to(0, 0) + _, maximum_box = resize_monitor_to( + viewport["width"] - 2, + viewport["height"] - 2, + ) + expect_close( + maximum_box["x"] + maximum_box["width"], + viewport["width"] - inset, + "maximum resize right inset", + ) + expect_close( + maximum_box["y"] + maximum_box["height"], + viewport["height"] - inset, + "maximum resize bottom inset", + ) + + # Do not leave the maximum-size overlay above the shutdown controls. + monitor.get_by_role("button", name = "Close").click() + monitor.wait_for(state = "hidden") + info( + "OK floating monitor preserves native resize and stays stable across " + "content, drag, and viewport changes" + ) + + with sync_playwright() as p: _watchdog = install_wall_clock_watchdog( WALL_TIMEOUT_S, @@ -1647,6 +1846,9 @@ with sync_playwright() as p: if page.get_by_role("dialog", name = re.compile(r"^Settings$")).count() != 0: fail("settings shortcut on /login left the dialog open after authentication") info("OK persisted monitor stayed dormant on /login and resumed after authentication") + + exercise_floating_monitor_geometry(page) + shoot("18-relogin-with-NEW2") step("Shutdown via account menu") From 4f6798584b5e91528d23d80f896ef3322b1a32dc Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 21:25:05 +0000 Subject: [PATCH 2/3] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tests/studio/playwright_chat_ui.py | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/tests/studio/playwright_chat_ui.py b/tests/studio/playwright_chat_ui.py index 78a49a5f07..2019959711 100644 --- a/tests/studio/playwright_chat_ui.py +++ b/tests/studio/playwright_chat_ui.py @@ -309,7 +309,11 @@ def exercise_floating_monitor_geometry(page): ) return monitor_box("drag") - def resize_monitor_to(x, y, grip_inset = 8): + def resize_monitor_to( + x, + y, + grip_inset = 8, + ): before = monitor_box("resize") pointer_drag( before["x"] + before["width"] - grip_inset, @@ -327,10 +331,8 @@ def exercise_floating_monitor_geometry(page): return ( box["x"] >= inset - tolerance and box["y"] >= inset - tolerance - and box["x"] + box["width"] - <= surface["width"] - inset + tolerance - and box["y"] + box["height"] - <= surface["height"] - inset + tolerance + and box["x"] + box["width"] <= surface["width"] - inset + tolerance + and box["y"] + box["height"] <= surface["height"] - inset + tolerance ) initial_box = monitor_box("initial placement") @@ -363,16 +365,12 @@ def exercise_floating_monitor_geometry(page): viewport["height"] - inset, "content growth bottom inset", ) - monitor.get_by_test_id("floating-monitor-growth-probe").evaluate( - "node => node.remove()" - ) + monitor.get_by_test_id("floating-monitor-growth-probe").evaluate("node => node.remove()") initial_box = wait_for_box( "content shrink", lambda box: ( abs(box["height"] - initial_box["height"]) <= tolerance - and abs( - box["y"] + box["height"] - viewport["height"] + inset - ) <= tolerance + and abs(box["y"] + box["height"] - viewport["height"] + inset) <= tolerance ), ) From 852ace56227b4633c72ce33bbc75fc9cb23df0ac Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Wed, 29 Jul 2026 09:17:24 +0000 Subject: [PATCH 3/3] 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";