diff --git a/studio/web/src/components/ImagePreviewModal.tsx b/studio/web/src/components/ImagePreviewModal.tsx
index 9be8804..c834d21 100644
--- a/studio/web/src/components/ImagePreviewModal.tsx
+++ b/studio/web/src/components/ImagePreviewModal.tsx
@@ -1,4 +1,5 @@
-import { useEffect } from 'react'
+import { useEffect, useRef } from 'react'
+import { createPortal } from 'react-dom'
import ZoomableImage from './ZoomableImage'
@@ -23,6 +24,9 @@ interface Props {
onAccept?: () => void
onDelete?: () => void
shortcutHint?: string
+ /** URLs to warm up in the background (usually the prev/next image), so flipping
+ * with the arrows shows an already-decoded image instead of loading from scratch. */
+ preload?: string[]
}
export default function ImagePreviewModal({
@@ -41,9 +45,15 @@ export default function ImagePreviewModal({
onAccept,
onDelete,
shortcutHint,
+ preload,
}: Props) {
+ // Handlers live in a ref so the keydown listener binds once: callers pass fresh
+ // inline closures on every render (and some re-render on every training tick).
+ const keysRef = useRef({ hasPrev, hasNext, onPrev, onNext, onClose, onAccept, onDelete })
+ keysRef.current = { hasPrev, hasNext, onPrev, onNext, onClose, onAccept, onDelete }
useEffect(() => {
const handler = (e: KeyboardEvent) => {
+ const { hasPrev, hasNext, onPrev, onNext, onClose, onAccept, onDelete } = keysRef.current
if (e.key === 'Escape') {
e.preventDefault()
onClose()
@@ -63,11 +73,35 @@ export default function ImagePreviewModal({
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
- }, [hasPrev, hasNext, onPrev, onNext, onClose, onAccept, onDelete])
+ }, [])
+
+ // The page behind stays put while the lightbox is open (no scroll-through on wheel / touch).
+ useEffect(() => {
+ const prev = document.body.style.overflow
+ document.body.style.overflow = 'hidden'
+ return () => { document.body.style.overflow = prev }
+ }, [])
+
+ // Warm up the neighbours; keyed by the joined URLs so a re-render with the same list is a no-op.
+ const preloadKey = (preload ?? []).filter(Boolean).join('\n')
+ useEffect(() => {
+ if (!preloadKey) return
+ const t = window.setTimeout(() => {
+ for (const u of preloadKey.split('\n')) {
+ const img = new Image()
+ img.decoding = 'async'
+ img.src = u
+ }
+ }, 150)
+ return () => window.clearTimeout(t)
+ }, [preloadKey])
const counter = index != null && total != null ? `${index + 1} / ${total}` : null
- return (
+ // Portaled to
: callers mount this deep inside cards, and an ancestor with opacity /
+ // transform (e.g. a finished queue card at opacity .72) would otherwise make the fixed
+ // overlay translucent, trap it under later siblings and composite the page behind every frame.
+ return createPortal(
{shortcutHint}
}
)}
-
+ ,
+ document.body,
)
}
@@ -152,6 +187,7 @@ function SplitPane({
src={src}
alt={label ?? altFallback ?? 'preview'}
onClick={(e) => e.stopPropagation()}
+ decoding="async"
className="max-w-full max-h-full object-contain"
/>
diff --git a/studio/web/src/components/MonitorDashboard.tsx b/studio/web/src/components/MonitorDashboard.tsx
index b77fdea..e06c4b5 100644
--- a/studio/web/src/components/MonitorDashboard.tsx
+++ b/studio/web/src/components/MonitorDashboard.tsx
@@ -4,7 +4,7 @@
* Data source: GET /api/state?task_id=N fetches a downsampled snapshot + SSE monitor_progress
* goes through the useMonitorProgress hook for delta merging (PR #37's incremental protocol).
*/
-import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
+import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { api, type EvalJobInfo, type EvalMetricResult, type EvalMetricState, type LoraCkpt, type MonitorState } from '../api/client'
import { evalProgressFromResults } from '../lib/useEvalProgress'
import { useMonitorProgress } from '../lib/useMonitorProgress'
@@ -840,7 +840,7 @@ function sampleMarks(s: { path: string; step?: number }): { step: number | null;
}
}
-function SampleViewer({ samples, taskId }: {
+const SampleViewer = memo(function SampleViewer({ samples, taskId }: {
samples: Array<{ path: string; step?: number }>
taskId: number
}) {
@@ -956,7 +956,7 @@ function SampleViewer({ samples, taskId }: {
key={fullUrl}
src={fullUrl}
alt="sample preview"
- loading="lazy"
+ decoding="async"
onClick={() => setZoomOpen(true)}
className="absolute inset-0 w-full h-full object-contain cursor-zoom-in"
/>
@@ -985,11 +985,14 @@ function SampleViewer({ samples, taskId }: {
onClose={() => setZoomOpen(false)}
onPrev={() => setActive((i) => Math.max(0, i - 1))}
onNext={() => setActive((i) => Math.min(list.length - 1, i + 1))}
+ preload={[list[active - 1], list[active + 1]]
+ .filter((x): x is { path: string; step?: number } => !!x)
+ .map((x) => api.sampleImageUrl(x.path.split(/[\\/]/).pop() ?? x.path, taskId))}
/>
)}
)
-}
+})
// ── Main Component ─────────────────────────────────────────────────────────
@@ -1056,6 +1059,15 @@ export default function MonitorDashboard({ taskId }: { taskId: number }) {
const vramTotal = state?.vram_total_gb
const vramTone = vram && vramTotal ? (vram / vramTotal > 0.85 ? 'warn' : 'ok') as 'ok' | 'warn' : undefined
+ // Full raw series (no more slice(-60)) -- SeriesChart downsamples evenly to 600 points internally when rendering.
+ // Memoized: the arrays run up to 50k points and the dashboard re-renders on every SSE delta, so the
+ // charts only rebuild when their own series actually changed.
+ const lossSeries = useMemo(() => losses.map((l) => ({ step: l.step, value: l.loss })), [losses])
+ const lrSeries = useMemo(() => lrHistory.map((l) => ({ step: l.step, value: l.lr })), [lrHistory])
+ const dSeries = useMemo(() => optimizerMetricsHistory
+ .filter((m) => typeof m.d === 'number')
+ .map((m) => ({ step: m.step, value: m.d as number })), [optimizerMetricsHistory])
+
if (!state && !connected) {
return (
@@ -1064,13 +1076,6 @@ export default function MonitorDashboard({ taskId }: { taskId: number }) {
)
}
- // Full raw series (no more slice(-60)) -- SeriesChart downsamples evenly to 600 points internally when rendering
- const lrSeries = lrHistory.map((l) => ({ step: l.step, value: l.lr }))
- const dSeries = optimizerMetricsHistory
- .map((m) => ({ step: m.step, d: m.d }))
- .filter((m): m is { step: number; d: number } => typeof m.d === 'number')
- .map((m) => ({ step: m.step, value: m.d }))
-
return (
{/* Connection status + progress */}
@@ -1165,7 +1170,7 @@ export default function MonitorDashboard({ taskId }: { taskId: number }) {