diff --git a/frontend/src/shared/state/dashboardLayoutSlice.ts b/frontend/src/shared/state/dashboardLayoutSlice.ts index 31a2232b..9be65157 100644 --- a/frontend/src/shared/state/dashboardLayoutSlice.ts +++ b/frontend/src/shared/state/dashboardLayoutSlice.ts @@ -1,3 +1,4 @@ +import { restoredCardPosition } from './restoredCardPosition'; import { createSlice, createAsyncThunk, PayloadAction, createAction } from '@reduxjs/toolkit'; import { launchAndSendFirstMessage, resumeSession, collapseSession, collapseAllSessions, setExpandedSessionIds } from './agentsSlice'; import { untileClosedChats } from './untileClosedChats'; @@ -631,6 +632,19 @@ export interface SpawnAnchor { viewportCenter?: { x: number; y: number }; } +// Which card map holds this id, plus the exclusion tag `collectOccupiedRects` needs so a card is +// never treated as blocking its own restore. +function p_placedCard(state: DashboardLayoutState, id: string): + { card: { x: number; y: number; width: number; height: number }; exclude: CardPlacementExclusion } | null { + const agent = Object.values(state.cards).find((c) => c.session_id === id); + if (agent) return { card: agent, exclude: { type: 'agent', id } }; + const view = Object.values(state.viewCards).find((c) => c.output_id === id); + if (view) return { card: view, exclude: { type: 'view', id } }; + const browser = Object.values(state.browserCards).find((c) => c.browser_id === id); + if (browser) return { card: browser, exclude: { type: 'browser', id } }; + return null; +} + export function computeSpawnPosition( state: DashboardLayoutState, newW: number, @@ -696,6 +710,19 @@ const dashboardLayoutSlice = createSlice({ const id = action.payload.cardId; if (state.minimizedCards[id]) { delete state.minimizedCards[id]; + // Coming back onto an occupied slot lands the card on top of whatever took its place while + // it was parked. Keep the user's spot when it is still free, reflow only when it is not. + const owner = p_placedCard(state, id); + if (owner) { + const occupied = collectOccupiedRects(state, undefined, owner.exclude); + const next = restoredCardPosition( + { x: owner.card.x, y: owner.card.y, w: owner.card.width, h: owner.card.height }, + occupied, + findOpenSpotNear, + ); + owner.card.x = next.x; + owner.card.y = next.y; + } } else { state.minimizedCards[id] = true; if (state.tiledCards[id] === 'fullscreen') delete state.tiledCards[id]; diff --git a/frontend/src/shared/state/restoredCardPosition.test.ts b/frontend/src/shared/state/restoredCardPosition.test.ts new file mode 100644 index 00000000..eebf1d42 --- /dev/null +++ b/frontend/src/shared/state/restoredCardPosition.test.ts @@ -0,0 +1,39 @@ +// Run: npm test (frontend/scripts/run-tests.mjs) +// +// Haik: "expanding a minimized card should use the same best-position placement logic that runs for +// every other spawning card" — it respawned into its original slot even when the layout had shifted +// under it, so it landed on top of whatever had moved in. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { restoredCardPosition, type Rect } from './restoredCardPosition.ts'; + +const CARD: Rect = { x: 100, y: 100, w: 300, h: 200 }; +// Stand-in for findOpenSpotNear: answers with a fixed marker so a reflow is unmistakable. +const SPOT = (x: number, y: number): { x: number; y: number } => ({ x: x + 999, y: y + 999 }); + +test('a card whose old spot is still free comes back exactly where it was left', () => { + const far: Rect[] = [{ x: 900, y: 900, w: 100, h: 100 }]; + assert.deepEqual(restoredCardPosition(CARD, far, SPOT), { x: 100, y: 100 }); +}); + +test('a card whose old spot is now taken reflows instead of landing on top', () => { + const onTop: Rect[] = [{ x: 150, y: 150, w: 300, h: 200 }]; + const got = restoredCardPosition(CARD, onTop, SPOT); + assert.deepEqual(got, { x: 1099, y: 1099 }, 'restored onto an occupied slot'); +}); + +test('the reflow is anchored on the old position, so the card stays near where it was parked', () => { + const onTop: Rect[] = [{ x: 150, y: 150, w: 300, h: 200 }]; + const calls: number[][] = []; + restoredCardPosition(CARD, onTop, (x, y) => { calls.push([x, y]); return { x, y }; }); + assert.deepEqual(calls, [[100, 100]], 'searched from somewhere other than the parked spot'); +}); + +test('edge contact is not an overlap, so touching cards do not trigger a pointless move', () => { + const flush: Rect[] = [{ x: 400, y: 100, w: 100, h: 200 }]; // starts exactly where CARD ends + assert.deepEqual(restoredCardPosition(CARD, flush, SPOT), { x: 100, y: 100 }); +}); + +test('an empty board never reflows', () => { + assert.deepEqual(restoredCardPosition(CARD, [], SPOT), { x: 100, y: 100 }); +}); diff --git a/frontend/src/shared/state/restoredCardPosition.ts b/frontend/src/shared/state/restoredCardPosition.ts new file mode 100644 index 00000000..b7934981 --- /dev/null +++ b/frontend/src/shared/state/restoredCardPosition.ts @@ -0,0 +1,35 @@ +// Where should a card land when you un-minimize it? (Haik, 2026-08-13) +// +// It used to land exactly where it was parked, which is right when that space is still empty and +// wrong when the board moved on without it: the card reappears on top of whatever took its place. +// +// So: keep the user's spot when it is still free, and only reflow when it is not. Always reflowing +// would be its own bug, because a user who parked a card somewhere deliberately expects it back +// there, and the nearest-free-spot search is anchored on the old position so even a reflowed card +// comes back close to where it was left rather than at the far end of the board. + +export interface Rect { + x: number; + y: number; + w: number; + h: number; +} + +/** + * @param current where the card was parked + * @param occupied every other card's rect (the restoring card MUST be excluded, or it collides + * with its own footprint and always reflows) + * @param findSpot nearest-free-spot search, injected so this stays free of the layout slice + */ +export function restoredCardPosition( + current: Rect, + occupied: Rect[], + findSpot: (x: number, y: number, occupied: Rect[], w: number, h: number) => { x: number; y: number }, +): { x: number; y: number } { + const clash = occupied.some((r) => ( + current.x < r.x + r.w && current.x + current.w > r.x + && current.y < r.y + r.h && current.y + current.h > r.y + )); + if (!clash) return { x: current.x, y: current.y }; + return findSpot(current.x, current.y, occupied, current.w, current.h); +}