diff --git a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts index 362ff93b..cd008b2e 100644 --- a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts +++ b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts @@ -81,13 +81,13 @@ export function useDashboardLifecycle({ // CRITICAL path: these populate the cards the user expects to see // on first paint. Don't defer. dispatch(fetchSessions({ dashboardId })); - dispatch(fetchLayout(dashboardId)); + dispatch(fetchLayout({ dashboardId })); const cleanupBrowserHandler = initBrowserCommandHandler(); // Global broadcasts (spawned browser cards) skip the replay log, so a // socket gap loses them; a reconnect refetch is the only way they return. const unsubReconnect = dashboardWs.on('dashboard:reconnected', () => { dispatch(fetchSessions({ dashboardId })); - dispatch(fetchLayout(dashboardId)); + dispatch(fetchLayout({ dashboardId, isReconnect: true })); }); // DEFERRABLE: history list (for the search palette) and outputs // (for the apps panel) aren't on the first-paint path. Same for the diff --git a/frontend/src/shared/state/dashboardLayoutSlice.ts b/frontend/src/shared/state/dashboardLayoutSlice.ts index 5c3859e5..4e188e0e 100644 --- a/frontend/src/shared/state/dashboardLayoutSlice.ts +++ b/frontend/src/shared/state/dashboardLayoutSlice.ts @@ -124,7 +124,11 @@ function generateTabId(): string { export const fetchLayout = createAsyncThunk( 'dashboardLayout/fetch', - async (dashboardId: string) => { + // isReconnect distinguishes a socket-reconnect recovery refetch (merge, keep + // live positions) from a fresh mount/switch load (replace, snapshot is the + // user's saved layout). Passed explicitly, not inferred from state, so a + // stale in-flight fetch from a previous dashboard can't be misread as a merge. + async ({ dashboardId }: { dashboardId: string; isReconnect?: boolean }) => { const res = await fetch(`${DASHBOARDS_API}/${dashboardId}`); const data = await res.json(); const layout = data.layout ?? {}; @@ -296,6 +300,27 @@ export function findOpenSpotNear( return findOpenGridCell(occupiedRects, newW, newH); } +// Reconnect-refetch merge: ADD only the cards the snapshot carries that the +// client is missing (e.g. a spawned browser whose broadcast was lost in a +// socket gap), collision-resolving each against the live layout so a recovered +// card can't land on a card already on canvas, and NEVER touch a card the +// client already has (that's exactly what preserves its live, collision-placed +// position). The shared `occupied` list carries placements forward so two +// recovered cards in the same pass also avoid each other. +function addMissingCards( + live: Record, + incoming: Record, + occupied: Rect[], +): void { + for (const id of Object.keys(incoming)) { + if (live[id]) continue; + const card = incoming[id]; + const pos = findOpenSpotNear(card.x, card.y, occupied, card.width, card.height); + live[id] = { ...card, x: pos.x, y: pos.y }; + occupied.push({ x: pos.x, y: pos.y, w: card.width, h: card.height }); + } +} + const dashboardLayoutSlice = createSlice({ name: 'dashboardLayout', initialState, @@ -924,11 +949,26 @@ const dashboardLayoutSlice = createSlice({ }) .addCase(fetchLayout.fulfilled, (state, action) => { state.loading = false; + // A fresh mount/switch load replaces (the snapshot is the user's saved + // layout, authoritative). A reconnect refetch (useDashboardLifecycle + // line ~90) recovers cards lost in a socket gap and must MERGE, blind- + // replacing there clobbered the live, collision-placed positions of + // cards already on canvas (the overlap / vanish under load while many + // browsers spawn). The caller says which; never inferred from state. + const isReconnectRefetch = action.meta.arg.isReconnect === true; state.initialized = true; - state.cards = action.payload.cards; - state.viewCards = action.payload.viewCards; - state.browserCards = action.payload.browserCards; - state.notes = action.payload.notes || {}; + if (!isReconnectRefetch) { + state.cards = action.payload.cards; + state.viewCards = action.payload.viewCards; + state.browserCards = action.payload.browserCards; + state.notes = action.payload.notes || {}; + } else { + const occupied = collectOccupiedRects(state, action.payload.expandedSessionIds); + addMissingCards(state.cards, action.payload.cards, occupied); + addMissingCards(state.viewCards, action.payload.viewCards, occupied); + addMissingCards(state.browserCards, action.payload.browserCards, occupied); + addMissingCards(state.notes, action.payload.notes || {}, occupied); + } state.persistedExpandedSessionIds = action.payload.expandedSessionIds; let maxZ = 0;