diff --git a/frontend/src/app/components/Layout/ReconnectingPill.tsx b/frontend/src/app/components/Layout/ReconnectingPill.tsx index 8ae5cc2a..e6d701a0 100644 --- a/frontend/src/app/components/Layout/ReconnectingPill.tsx +++ b/frontend/src/app/components/Layout/ReconnectingPill.tsx @@ -59,7 +59,10 @@ const ReconnectingPill: React.FC = () => { // centre (measured: centre 834 vs viewport centre 700). No transform, nothing to clobber. return createPortal( - + {/* Asymmetric on purpose: the pill arrives quickly because it is answering a question you already + have, and leaves slowly so the resolution registers as calm rather than a flicker. One element + that changes its contents, never two pills swapping, so nothing on screen appears to move. */} + window.location.reload() : undefined} role={showProblem ? 'button' : 'status'} @@ -90,12 +93,19 @@ const ReconnectingPill: React.FC = () => { ) : ( - <> + Reconnected - + )} diff --git a/frontend/src/shared/state/closedCardPositions.test.ts b/frontend/src/shared/state/closedCardPositions.test.ts new file mode 100644 index 00000000..9d2e6f23 --- /dev/null +++ b/frontend/src/shared/state/closedCardPositions.test.ts @@ -0,0 +1,66 @@ +// Run: node --test (via frontend/scripts/run-tests.mjs) +// +// closedCardPositions remembers where a closed card sat so reopening drops it back in place. Nothing +// pruned it and it is persisted, so it grew forever: 0 -> 555 across 250 clean lifecycles, measured. +// Capping it is right, but the cap has a sharp edge worth pinning: a transient empty answer +// reconciles EVERY card away at once and the restore reads these back, so a cap below the board size +// silently scatters the overflow into fresh grid cells. At a cap of 50, a 60-card board lost exactly +// 10 cards. That is the regression these tests exist to stop. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import reducer, { reconcileSessions } from './dashboardLayoutSlice.ts'; + +function board(n: number) { + let s: any = reducer(undefined, { type: '@@init' }); + const want: Record = {}; + for (let i = 0; i < n; i++) { + const id = 'c' + i, x = 100 + i * 37, y = 200 + i * 11; + want[id] = { x, y }; + s = reducer(s, { type: 'dashboardLayout/placeCard', + payload: { sessionId: id, x, y, width: 480, height: 280, expandedSessionIds: [], exact: true } }); + } + return { s, want, ids: Object.keys(s.cards) }; +} + +test('a card that vanishes and comes back lands exactly where it was', () => { + // Note which path remembers: reconcileSessions (the session went away) records the position, an + // explicit removeCard (you pressed close) deliberately does not. Testing the wrong one reads as a + // product bug when it is just a different intent. + const { s, want } = board(1); + let next: any = reducer(s, reconcileSessions({ sessionIds: [], expandedSessionIds: [] })); + next = reducer(next, reconcileSessions({ sessionIds: ['c0'], expandedSessionIds: [] })); + assert.equal(next.cards.c0.x, want.c0.x); + assert.equal(next.cards.c0.y, want.c0.y); +}); + +test('a whole big board survives a strip-and-restore with every spot intact', () => { + // The failure this pins: 60 cards, cap 50, 10 land in fresh grid cells instead of home. + const N = 60; + const { s, want, ids } = board(N); + let next: any = reducer(s, reconcileSessions({ sessionIds: [], expandedSessionIds: [] })); + next = reducer(next, reconcileSessions({ sessionIds: ids, expandedSessionIds: [] })); + const moved = ids.filter((id) => next.cards[id].x !== want[id].x || next.cards[id].y !== want[id].y); + assert.equal(moved.length, 0, `${moved.length} of ${N} cards lost their saved position`); +}); + +test('the map is still BOUNDED, so the leak cannot come back', () => { + // Close far more cards than any real board to prove the cap engages at all. + let s: any = reducer(undefined, { type: '@@init' }); + for (let i = 0; i < 1400; i++) { + s = reducer(s, { type: 'dashboardLayout/placeCard', + payload: { sessionId: 'x' + i, x: i, y: i, width: 480, height: 280, expandedSessionIds: [] } }); + s = reducer(s, reconcileSessions({ sessionIds: [], expandedSessionIds: [] })); + } + const kept = Object.keys(s.closedCardPositions).length; + assert.ok(kept <= 1000, `closedCardPositions grew to ${kept}, cap not engaging`); + assert.ok(kept > 500, `cap is too tight at ${kept}; a real board would lose spots`); +}); + +test('a card that vanishes twice keeps ONE entry, refreshed, not two', () => { + const { s } = board(1); + let next: any = reducer(s, reconcileSessions({ sessionIds: [], expandedSessionIds: [] })); + const first = Object.keys(next.closedCardPositions).length; + next = reducer(next, reconcileSessions({ sessionIds: ['c0'], expandedSessionIds: [] })); + next = reducer(next, reconcileSessions({ sessionIds: [], expandedSessionIds: [] })); + assert.equal(Object.keys(next.closedCardPositions).length, first, 'a re-close duplicated the entry'); +}); diff --git a/frontend/src/shared/state/dashboardLayoutSlice.ts b/frontend/src/shared/state/dashboardLayoutSlice.ts index e15ebd5f..31a2232b 100644 --- a/frontend/src/shared/state/dashboardLayoutSlice.ts +++ b/frontend/src/shared/state/dashboardLayoutSlice.ts @@ -136,7 +136,12 @@ const RECENTLY_CLOSED_CAP = 25; // entries from 250 closes (the reconcile path records one too, so a close can bill twice). A position // from hundreds of cards ago has no value, nobody reopens that, so keep the recent ones and drop the // rest. Insertion order on a string-keyed object is the age order we need. -const CLOSED_POSITIONS_CAP = 50; +// +// The cap must clear the LARGEST board, not the typical one: a transient empty answer reconciles +// every card away at once and the restore reads these back, so a cap below the card count silently +// scatters the overflow into fresh grid cells. Measured at a cap of 50, a 60-card board lost exactly +// 10 cards, which is why this number is not 50. +const CLOSED_POSITIONS_CAP = 1000; function rememberClosedPosition( map: Record, id: string, pos: CardPosition,