diff --git a/src/ui/src/dashboard/DashboardLayout.tsx b/src/ui/src/dashboard/DashboardLayout.tsx
index ab9f94c9..3f9e731a 100644
--- a/src/ui/src/dashboard/DashboardLayout.tsx
+++ b/src/ui/src/dashboard/DashboardLayout.tsx
@@ -1,7 +1,7 @@
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useConfirm } from '@/components/ui/confirm-dialog'
import { Lock, Plus, Settings, Unlock } from 'lucide-react'
-import { ResponsiveGridLayout } from 'react-grid-layout'
+import { GridLayout } from 'react-grid-layout'
import 'react-grid-layout/css/styles.css'
import 'react-resizable/css/styles.css'
import DashboardHeader from './DashboardHeader'
@@ -12,7 +12,14 @@ import DashboardSettingsDialog from './components/DashboardSettingsDialog'
import SearchWidgetSettings from './components/SearchWidgetSettings'
import { getWidgetMeta, isWidgetCategoryEnabled } from './widgets/registry'
import { useModules } from '@/hooks/useModules'
-import { GRID_ROW_HEIGHT, GRID_MARGIN, computeAvailableRows, fitWidgetHeight } from './utils/grid-utils'
+import {
+ GRID_COLS,
+ GRID_ROW_HEIGHT,
+ GRID_MARGIN,
+ computeAvailableRows,
+ fitWidgetHeight,
+ mergeLayoutIntoWidgets,
+} from './utils/grid-utils'
import { resolveTimeRange, type CalendarPreset } from './utils/resolveTimeRange'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
@@ -41,10 +48,11 @@ function DashboardGrid() {
const [containerWidth, setContainerWidth] = useState(0)
const [containerHeight, setContainerHeight] = useState(0)
const widgetsRef = useRef(widgets)
- const skipLayoutChangeRef = useRef(false)
+ const lockedRef = useRef(locked)
const { widgetControl } = useModules()
useEffect(() => { widgetsRef.current = widgets }, [widgets])
+ useEffect(() => { lockedRef.current = locked }, [locked])
useEffect(() => {
const el = containerRef.current
@@ -86,28 +94,18 @@ function DashboardGrid() {
}
}), [widgets, locked])
- const handleLayoutChange = useCallback((newLayout) => {
- if (skipLayoutChangeRef.current) {
- skipLayoutChangeRef.current = false
- return
- }
- const current = widgetsRef.current
- let changed = false
- const updated = current.map((w) => {
- const item = newLayout.find((l) => l.i === w.id)
- if (item && (w.x !== item.x || w.y !== item.y || w.w !== item.w || w.h !== item.h)) {
- changed = true
- return { ...w, x: item.x, y: item.y, w: item.w, h: item.h }
- }
- return w
- })
- if (changed) updateWidgets(updated)
+ // Persist only after intentional drag/resize. Do not use onLayoutChange:
+ // responsive breakpoint compaction previously overwrote the saved layout
+ // (and auto-saved it) when the window or DevTools width changed (#906).
+ const handleLayoutCommit = useCallback((newLayout) => {
+ if (lockedRef.current) return
+ const updated = mergeLayoutIntoWidgets(widgetsRef.current, newLayout)
+ if (updated) updateWidgets(updated)
}, [updateWidgets])
const availableRows = computeAvailableRows(containerHeight)
const handleAddWidget = (widget) => {
- skipLayoutChangeRef.current = true
if (locked) setLocked(false)
const meta = getWidgetMeta(widget.type)
const clampedH = fitWidgetHeight(widget.h ?? meta?.defaultH ?? 3, meta?.minH ?? 2, availableRows)
@@ -115,12 +113,10 @@ function DashboardGrid() {
}
const handleRemoveWidget = (widgetId) => {
- skipLayoutChangeRef.current = true
updateWidgets(widgets.filter((w) => w.id !== widgetId))
}
const handleDuplicateWidget = (widget) => {
- skipLayoutChangeRef.current = true
const meta = getWidgetMeta(widget.type)
const clampedH = fitWidgetHeight(widget.h ?? meta?.defaultH ?? 3, meta?.minH ?? 2, availableRows)
const copy = { ...widget, id: `${widget.type}-${Date.now()}`, x: 0, y: Infinity, h: clampedH }
@@ -331,16 +327,19 @@ function DashboardGrid() {
)}
{!loading && activeDashboardId && widgets.length > 0 && containerWidth > 0 && (
-
{widgets.map((widget) => {
const meta = getWidgetMeta(widget.type)
@@ -390,7 +389,7 @@ function DashboardGrid() {
)
})}
-
+
)}
diff --git a/src/ui/src/dashboard/utils/grid-utils.test.ts b/src/ui/src/dashboard/utils/grid-utils.test.ts
new file mode 100644
index 00000000..66152803
--- /dev/null
+++ b/src/ui/src/dashboard/utils/grid-utils.test.ts
@@ -0,0 +1,47 @@
+import { describe, expect, it } from 'vitest'
+import { fitWidgetHeight, mergeLayoutIntoWidgets } from './grid-utils'
+
+describe('mergeLayoutIntoWidgets', () => {
+ const widgets = [
+ { id: 'a', type: 'search', x: 0, y: 0, w: 6, h: 3 },
+ { id: 'b', type: 'result', x: 6, y: 0, w: 6, h: 3 },
+ ]
+
+ it('returns null when layout positions are unchanged', () => {
+ const layout = [
+ { i: 'a', x: 0, y: 0, w: 6, h: 3 },
+ { i: 'b', x: 6, y: 0, w: 6, h: 3 },
+ ]
+ expect(mergeLayoutIntoWidgets(widgets, layout)).toBeNull()
+ })
+
+ it('updates only widgets whose grid position changed', () => {
+ const layout = [
+ { i: 'a', x: 0, y: 0, w: 6, h: 3 },
+ { i: 'b', x: 0, y: 3, w: 12, h: 4 },
+ ]
+ expect(mergeLayoutIntoWidgets(widgets, layout)).toEqual([
+ { id: 'a', type: 'search', x: 0, y: 0, w: 6, h: 3 },
+ { id: 'b', type: 'result', x: 0, y: 3, w: 12, h: 4 },
+ ])
+ })
+
+ it('ignores layout entries for unknown widget ids', () => {
+ const layout = [
+ { i: 'a', x: 1, y: 2, w: 4, h: 5 },
+ { i: 'ghost', x: 0, y: 0, w: 12, h: 1 },
+ ]
+ expect(mergeLayoutIntoWidgets(widgets, layout)).toEqual([
+ { id: 'a', type: 'search', x: 1, y: 2, w: 4, h: 5 },
+ { id: 'b', type: 'result', x: 6, y: 0, w: 6, h: 3 },
+ ])
+ })
+})
+
+describe('fitWidgetHeight', () => {
+ it('clamps default height into available rows', () => {
+ expect(fitWidgetHeight(8, 2, 5)).toBe(5)
+ expect(fitWidgetHeight(1, 2, 5)).toBe(2)
+ expect(fitWidgetHeight(3, 2, 0)).toBe(3)
+ })
+})
diff --git a/src/ui/src/dashboard/utils/grid-utils.ts b/src/ui/src/dashboard/utils/grid-utils.ts
index da513993..5e8194be 100644
--- a/src/ui/src/dashboard/utils/grid-utils.ts
+++ b/src/ui/src/dashboard/utils/grid-utils.ts
@@ -1,5 +1,23 @@
export const GRID_ROW_HEIGHT = 60
export const GRID_MARGIN = 10
+/** Canonical dashboard column count (matches dashboard config.columns). */
+export const GRID_COLS = 12
+
+export interface GridLayoutPosition {
+ i: string
+ x: number
+ y: number
+ w: number
+ h: number
+}
+
+export interface WidgetGridPosition {
+ id: string
+ x?: number
+ y?: number
+ w?: number
+ h?: number
+}
/** How many grid rows fit in the given pixel height. */
export function computeAvailableRows(
@@ -20,3 +38,26 @@ export function fitWidgetHeight(
if (availableRows <= 0) return defaultH
return Math.max(minH, Math.min(defaultH, availableRows))
}
+
+/**
+ * Apply react-grid-layout positions onto widgets.
+ * Returns a new array when any x/y/w/h changed, otherwise null.
+ */
+export function mergeLayoutIntoWidgets(
+ widgets: T[],
+ newLayout: GridLayoutPosition[],
+): T[] | null {
+ let changed = false
+ const updated = widgets.map((w) => {
+ const item = newLayout.find((l) => l.i === w.id)
+ if (
+ item &&
+ (w.x !== item.x || w.y !== item.y || w.w !== item.w || w.h !== item.h)
+ ) {
+ changed = true
+ return { ...w, x: item.x, y: item.y, w: item.w, h: item.h }
+ }
+ return w
+ })
+ return changed ? updated : null
+}
diff --git a/src/version.go b/src/version.go
index b6abeda6..7bdb0a26 100644
--- a/src/version.go
+++ b/src/version.go
@@ -24,7 +24,7 @@ import (
// Version information for homer-core
var (
// VERSION_APPLICATION is the application version
- VERSION_APPLICATION = "11.0.303"
+ VERSION_APPLICATION = "11.0.304"
// BuildDate is the build date
BuildDate = ""