[eric] canvas: un-minimizing keeps your spot when it is free and reflows when it is not

This commit is contained in:
ciregenz
2026-08-13 20:07:37 -07:00
parent 408d6f4f79
commit b548f3ba07
3 changed files with 101 additions and 0 deletions
@@ -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];
@@ -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 });
});
@@ -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);
}