[eric] canvas: fullscreen can no longer strand a chromeless shell (ghost-id guard, one owner, layout-fetch prune, always-clickable exit pill)

This commit is contained in:
ciregenz
2026-08-09 07:48:30 -07:00
parent 02cc65632a
commit 4ebf9a91f6
2 changed files with 40 additions and 4 deletions
@@ -551,6 +551,23 @@ const DashboardCanvas: React.FC<DashboardCanvasProps> = ({
/>
</Box>
{/* The one exit that survives everything: mouse events over a fullscreen webview go to the guest, so hover reveals and window-level Escape can both be unreachable; this pill is an embedder element painted above the guest and always clickable. */}
{fullscreenCardId && (
<Box
onClick={() => dispatch(clearTiledCard(fullscreenCardId))}
sx={{
position: 'fixed', top: 34, left: '50%', transform: 'translateX(-50%)', zIndex: 1400,
px: '12px', py: '5px', borderRadius: 999, cursor: 'pointer', userSelect: 'none',
background: 'rgba(24,14,32,0.6)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)',
color: 'rgba(255,255,255,0.9)', fontSize: 11, fontWeight: 600, letterSpacing: 0.2,
opacity: 0.3, transition: 'opacity 140ms ease', '&:hover': { opacity: 1 },
WebkitAppRegion: 'no-drag',
}}
>
Exit full screen
</Box>
)}
</Box>
</>
);
@@ -251,6 +251,13 @@ interface LayoutPayload {
zOrders?: Record<string, number>;
}
// Window-state entries (tiles, minimized pills) are only meaningful while some card map owns the id.
function tileOwnerExists(s: DashboardLayoutState, id: string): boolean {
return id in s.cards || id in s.viewCards || id in s.browserCards || id in s.workflowCards
|| (id === WORKFLOWS_HUB_ID && !!s.workflowsHub) || (id === SETTINGS_CARD_ID && !!s.settingsCard)
|| (id === MARKETPLACE_CARD_ID && !!s.marketplaceCard);
}
function ledgerAdd(ledger: string[], id: string): void {
if (!ledger.includes(id)) ledger.push(id);
}
@@ -670,6 +677,14 @@ const dashboardLayoutSlice = createSlice({
},
setTiledCard(state, action: PayloadAction<{ cardId: string; zone: string }>) {
const { cardId, zone } = action.payload;
// The rail defers its tile dispatch two frames, so the card can be gone by the time it lands; a stranded 'fullscreen' hides the whole shell.
if (!tileOwnerExists(state, cardId)) return;
// One fullscreen owner, ever: two entries would fight over who hides the chrome and who gets the Escape.
if (zone === 'fullscreen') {
for (const [id, z] of Object.entries(state.tiledCards)) {
if (z === 'fullscreen' && id !== cardId) delete state.tiledCards[id];
}
}
state.tiledCards[cardId] = zone;
if (state.minimizedCards[cardId]) delete state.minimizedCards[cardId];
},
@@ -1867,6 +1882,13 @@ const dashboardLayoutSlice = createSlice({
state.browserCards = { ...incoming, ...keptAlive };
state.workflowCards = action.payload.workflowCards || {};
state.workflowsHub = action.payload.workflowsHub || null;
// The wholesale replace bypasses every removal reducer, so window-state entries can orphan; a stale 'fullscreen' re-engages the moment the same id reappears, hiding all chrome with zero clicks.
for (const id of Object.keys(state.tiledCards)) {
if (!tileOwnerExists(state, id)) delete state.tiledCards[id];
}
for (const id of Object.keys(state.minimizedCards)) {
if (!tileOwnerExists(state, id)) delete state.minimizedCards[id];
}
} else {
const occupied = collectOccupiedRects(state, action.payload.expandedSessionIds);
addMissingCards(state.cards, action.payload.cards, occupied);
@@ -2103,10 +2125,7 @@ export const selectFullscreenCardId = (state: { dashboardLayout: DashboardLayout
if (!entry) return null;
const id = entry[0];
// Belt over the reducer hygiene: an entry whose card is gone (any removal path) must not hold the app in fullscreen.
const exists = id in s.cards || id in s.viewCards || id in s.browserCards || id in s.workflowCards
|| (id === WORKFLOWS_HUB_ID && !!s.workflowsHub) || (id === SETTINGS_CARD_ID && !!s.settingsCard)
|| (id === MARKETPLACE_CARD_ID && !!s.marketplaceCard);
return exists && !s.minimizedCards[id] ? id : null;
return tileOwnerExists(s, id) && !s.minimizedCards[id] ? id : null;
};
export default dashboardLayoutSlice.reducer;