Merge 852ace5622 into 3212710a4a
This commit is contained in:
commit
4ebd079682
2 changed files with 633 additions and 120 deletions
|
|
@ -4,18 +4,285 @@
|
|||
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;
|
||||
minHeight: 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));
|
||||
}
|
||||
|
||||
// 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(() => {
|
||||
const monitor = monitorRef.current;
|
||||
const constraints = constraintsElement;
|
||||
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 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;
|
||||
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: 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
|
||||
: next;
|
||||
});
|
||||
};
|
||||
|
||||
reconcileGeometry();
|
||||
const observer = new ResizeObserver(reconcileGeometry);
|
||||
observer.observe(constraints);
|
||||
observer.observe(monitor);
|
||||
// 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>) {
|
||||
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<HTMLDivElement>) {
|
||||
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<HTMLDivElement>) {
|
||||
if (dragSessionRef.current?.pointerId === event.pointerId) {
|
||||
dragSessionRef.current = null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
monitorRef,
|
||||
scrollRef,
|
||||
contentRef,
|
||||
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 +312,27 @@ 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<typeof useSystemInfo>;
|
||||
}
|
||||
|
||||
function FloatingMonitorPanel({
|
||||
onClose,
|
||||
systemInfo,
|
||||
}: FloatingMonitorPanelProps) {
|
||||
const t = useT();
|
||||
const [constraintsElement, setConstraintsElement] =
|
||||
useState<HTMLDivElement | null>(null);
|
||||
const constraintsRef = useMemo(
|
||||
() => ({ current: constraintsElement }),
|
||||
[constraintsElement],
|
||||
);
|
||||
const dragControls = useDragControls();
|
||||
|
||||
function startDrag(event: PointerEvent<HTMLDivElement>) {
|
||||
event.preventDefault();
|
||||
dragControls.start(event);
|
||||
}
|
||||
const {
|
||||
monitorRef,
|
||||
scrollRef,
|
||||
contentRef,
|
||||
layout,
|
||||
startDrag,
|
||||
updateDrag,
|
||||
finishDrag,
|
||||
} = useMonitorLayout(constraintsElement);
|
||||
|
||||
const ramTotal = systemInfo.memory?.total_gb ?? 0;
|
||||
const ramAvailable = systemInfo.memory?.available_gb ?? 0;
|
||||
|
|
@ -98,124 +369,165 @@ export function FloatingMonitor() {
|
|||
const hasGpu = (displayedGpu?.available ?? false) && devices.length > 0;
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<div
|
||||
ref={setConstraintsElement}
|
||||
className="fixed inset-0 z-50 pointer-events-none"
|
||||
>
|
||||
<motion.div
|
||||
drag={true}
|
||||
dragControls={dragControls}
|
||||
dragListener={false}
|
||||
dragConstraints={constraintsRef}
|
||||
dragElastic={0}
|
||||
dragMomentum={false}
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.9 }}
|
||||
className="settings-surface fixed bottom-4 right-4 w-64 max-w-[calc(100vw-2rem)] resize overflow-hidden rounded-xl border border-border/70 p-3 shadow-border ring-0 backdrop-blur-sm pointer-events-auto cursor-default select-none"
|
||||
>
|
||||
<div className="mb-2 flex items-center justify-between gap-2 border-b border-border/60 pb-2">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1.5 truncate text-xs font-semibold text-foreground">
|
||||
<CpuIcon className="size-3.5 shrink-0 text-primary" />
|
||||
<span className="truncate">
|
||||
{t("settings.resources.liveMonitor.title")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<div
|
||||
onPointerDown={startDrag}
|
||||
className="touch-none cursor-grab rounded-md px-1 text-muted-foreground/60 transition-colors hover:bg-muted/60 hover:text-muted-foreground active:cursor-grabbing"
|
||||
>
|
||||
<GripVerticalIcon className="size-3.5" />
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setIsOpen(false)}
|
||||
title={t("common.close")}
|
||||
aria-label={t("common.close")}
|
||||
>
|
||||
<XIcon className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
ref={setConstraintsElement}
|
||||
className="pointer-events-none fixed inset-4 z-50"
|
||||
>
|
||||
<motion.div
|
||||
ref={monitorRef}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className={cn(
|
||||
"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"
|
||||
style={
|
||||
layout
|
||||
? {
|
||||
left: layout.left,
|
||||
top: layout.top,
|
||||
minWidth: Math.min(layout.minWidth, layout.maxWidth),
|
||||
minHeight: Math.min(layout.minHeight, layout.maxHeight),
|
||||
maxWidth: layout.maxWidth,
|
||||
maxHeight: layout.maxHeight,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<div className="mb-2 flex items-center justify-between gap-2 border-b border-border/60 pb-2">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1.5 truncate text-xs font-semibold text-foreground">
|
||||
<CpuIcon className="size-3.5 shrink-0 text-primary" />
|
||||
<span className="truncate">
|
||||
{t("settings.resources.liveMonitor.title")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<div
|
||||
data-testid="floating-monitor-drag-handle"
|
||||
onPointerDown={startDrag}
|
||||
onPointerMove={updateDrag}
|
||||
onPointerUp={finishDrag}
|
||||
onPointerCancel={finishDrag}
|
||||
onLostPointerCapture={finishDrag}
|
||||
className="touch-none cursor-grab rounded-md px-1 text-muted-foreground/60 transition-colors hover:bg-muted/60 hover:text-muted-foreground active:cursor-grabbing"
|
||||
>
|
||||
<GripVerticalIcon className="size-3.5" />
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="space-y-3 overflow-hidden"
|
||||
<Button
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={onClose}
|
||||
title={t("common.close")}
|
||||
aria-label={t("common.close")}
|
||||
>
|
||||
<XIcon className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>{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 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="truncate flex-1 pr-2">
|
||||
{t("settings.resources.liveMonitor.vram")}{" "}
|
||||
{devices.length > 1
|
||||
? `(${devices.length} GPUs)`
|
||||
: `(${devices[0].name ?? "GPU"})`}
|
||||
</span>
|
||||
<span
|
||||
className={cn("tabular-nums", usageTextClass(ramPercent))}
|
||||
className={cn(
|
||||
"shrink-0 tabular-nums",
|
||||
vramUsageKnown
|
||||
? usageTextClass(vramPercent)
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{Math.round(ramPercent)}%
|
||||
{vramUsageKnown ? `${Math.round(vramPercent)}%` : "--"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground font-mono tabular-nums">
|
||||
{formatGiB(ramUsed)} / {formatGiB(ramTotal)}
|
||||
{vramUsageKnown ? formatGiB(vramUsed) : unknownLabel} /{" "}
|
||||
{formatGiB(vramTotal)}
|
||||
</div>
|
||||
<Progress
|
||||
value={ramPercent}
|
||||
value={vramUsageKnown ? vramPercent : 0}
|
||||
className="mt-1 h-1.5 rounded-full bg-muted"
|
||||
indicatorClassName={usageIndicatorClass(ramPercent)}
|
||||
indicatorClassName={usageIndicatorClass(vramPercent)}
|
||||
/>
|
||||
</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>
|
||||
)}
|
||||
</motion.div>
|
||||
</motion.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>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<FloatingMonitorPanel
|
||||
key={panelKey}
|
||||
systemInfo={systemInfo}
|
||||
onClose={() => setIsOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -263,6 +263,204 @@ 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.
|
||||
# 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";
|
||||
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 +1845,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")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue