diff --git a/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx b/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx index 73c24a3b..cf4a7fad 100644 --- a/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx @@ -18,6 +18,7 @@ import KeyboardArrowUpRounded from '@mui/icons-material/KeyboardArrowUpRounded'; import { Output, SERVE_BASE } from '@/shared/state/outputsSlice'; import { setViewCardPosition, setViewDocked, setViewCardSize, setActiveViewCardId, recordClosedCard, addViewCard, setTiledCard, clearTiledCard, toggleMinimizeCard, activateViewCardPreview } from '@/shared/state/dashboardLayoutSlice'; import { removeViewCardCleanly } from '@/shared/viewTeardown'; +import { requestAppSlot, releaseAppSlot, subscribeAppBudget } from '@/shared/appWebviewBudget'; import { expandSession } from '@/shared/state/agentsSlice'; import WindowControls from './WindowControls'; import { openCardContextMenu } from '../desktop/CardContextMenu'; @@ -56,6 +57,7 @@ const MIN_H = 200; // has no login/scroll state worth keeping, so we just unmount the webview when the card is off-screen or // too small to read, and remount (reload) on return. Asymmetric: resume instantly, suspend after a beat // so panning past a card doesn't reload it. +const isElectron = typeof navigator !== 'undefined' && navigator.userAgent.includes('Electron'); const APP_PREVIEW_MIN_PX = 260; // below this on-screen width the live page is indistinguishable from a still const APP_PREVIEW_MARGIN_PX = 400; // resume once the card is within this of the viewport const APP_SUSPEND_SETTLE_MS = 1200; @@ -224,23 +226,37 @@ const DashboardViewCard: React.FC = ({ if (previewDeferred) { want = false; // reveal-parked: never boot until the first click clears the defer } else if (alwaysLive) { + // Actively used (selected, interacting, agent-driven, tiled, fullscreen): pinned, never capped. + if (isElectron) requestAppSlot(cardKey, 0, true); want = true; } else { const now = getCanvasState(); const vpEl = document.querySelector('[data-canvas-viewport]'); const vpW = vpEl ? (vpEl as HTMLElement).clientWidth : window.innerWidth; const vpH = vpEl ? (vpEl as HTMLElement).clientHeight : window.innerHeight; + let onscreen: boolean; if (cardWidth * now.zoom < APP_PREVIEW_MIN_PX) { - want = false; + onscreen = false; } else { const m = APP_PREVIEW_MARGIN_PX / now.zoom; const vx = -now.panX / now.zoom - m; const vy = -now.panY / now.zoom - m; const vw = vpW / now.zoom + 2 * m; const vh = vpH / now.zoom + 2 * m; - want = cardX < vx + vw && cardX + cardWidth > vx && cardY < vy + vh && cardY + cardHeight > vy; + onscreen = cardX < vx + vw && cardX + cardWidth > vx && cardY < vy + vh && cardY + cardHeight > vy; + } + if (!onscreen || !isElectron) { + want = onscreen; // non-Electron previews are cheap iframes, no renderer to cap + } else { + // On-screen but passive: go live only if the hard cap has a slot; closest-to-center wins it. + const cx = (-now.panX + vpW / 2) / now.zoom; + const cy = (-now.panY + vpH / 2) / now.zoom; + const ddx = cardX + cardWidth / 2 - cx; + const ddy = cardY + cardHeight / 2 - cy; + want = requestAppSlot(cardKey, ddx * ddx + ddy * ddy, false); } } + if (!want) releaseAppSlot(cardKey); if (want === previewLiveRef.current) return; if (want) { if (suspendTimerRef.current) { clearTimeout(suspendTimerRef.current); suspendTimerRef.current = null; } @@ -265,14 +281,19 @@ const DashboardViewCard: React.FC = ({ } }; evaluate(); + const unsubBudget = subscribeAppBudget(evaluate); // an eviction or a freed slot re-runs this card's decision window.addEventListener('openswarm:canvas-pan-changed', evaluate); window.addEventListener('resize', evaluate); return () => { + unsubBudget(); window.removeEventListener('openswarm:canvas-pan-changed', evaluate); window.removeEventListener('resize', evaluate); if (suspendTimerRef.current) { clearTimeout(suspendTimerRef.current); suspendTimerRef.current = null; } }; - }, [alwaysLive, previewDeferred, cardX, cardY, cardWidth, cardHeight, getCanvasState]); + }, [alwaysLive, previewDeferred, cardX, cardY, cardWidth, cardHeight, getCanvasState, cardKey]); + + // Free the cap slot on unmount (card deleted, dashboard switch) so a slot is never leaked. + useEffect(() => () => releaseAppSlot(cardKey), [cardKey]); // Deselecting the card exits interact mode (click anywhere else on canvas). useEffect(() => { diff --git a/frontend/src/shared/appWebviewBudget.test.ts b/frontend/src/shared/appWebviewBudget.test.ts new file mode 100644 index 00000000..3497ea93 --- /dev/null +++ b/frontend/src/shared/appWebviewBudget.test.ts @@ -0,0 +1,67 @@ +// Run: node --test frontend/src/shared/appWebviewBudget.test.ts +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + requestAppSlot, + releaseAppSlot, + MAX_LIVE_APP_WEBVIEWS as MAX, +} from './appWebviewBudget.ts'; + +// The module holds shared state, so every test fills, asserts, then releases its own keys to leave a clean slate. +function fill(prefix: string, n: number, basePriority: number): string[] { + const keys: string[] = []; + for (let i = 0; i < n; i++) { + const k = `${prefix}${i}`; + assert.equal(requestAppSlot(k, basePriority + i, false), true, `slot ${i} should be granted below cap`); + keys.push(k); + } + return keys; +} +function release(keys: string[]): void { + for (const k of keys) releaseAppSlot(k); +} + +test('grants every request below the cap', () => { + const keys = fill('a', MAX, 10); + release(keys); +}); + +test('denies a farther card once the cap is full', () => { + const keys = fill('b', MAX, 10); + assert.equal(requestAppSlot('b-far', 9999, false), false, 'a card farther than all live cards is denied'); + release([...keys, 'b-far']); +}); + +test('a closer card evicts the farthest, and the evicted one is then denied', () => { + const keys = fill('c', MAX, 100); // priorities 100..100+MAX-1; farthest is the last + assert.equal(requestAppSlot('c-near', 1, false), true, 'a closer card takes a slot by eviction'); + // The farthest original (highest priority) was evicted; re-requesting it now fails (still full, still farthest). + const evicted = `c${MAX - 1}`; + assert.equal(requestAppSlot(evicted, 100 + MAX - 1, false), false, 'the evicted farthest card cannot re-enter'); + release([...keys, 'c-near']); +}); + +test('pinned cards bypass the cap and are never evicted', () => { + const keys = fill('d', MAX, 10); + assert.equal(requestAppSlot('d-pin', 0, true), true, 'pinned card is admitted past a full cap'); + // A pinned card does not consume an evictable slot, so an unpinned farther card is still denied. + assert.equal(requestAppSlot('d-far', 9999, false), false, 'unpinned farther card still denied with a pin present'); + // Another pin also admitted. + assert.equal(requestAppSlot('d-pin2', 0, true), true, 'second pinned card also admitted'); + release([...keys, 'd-pin', 'd-pin2', 'd-far']); +}); + +test('releasing a slot lets a previously-denied card in', () => { + const keys = fill('e', MAX, 10); + assert.equal(requestAppSlot('e-wait', 9999, false), false, 'denied while full'); + releaseAppSlot(keys[0]); + assert.equal(requestAppSlot('e-wait', 9999, false), true, 'admitted after a slot frees'); + release([keys[1], keys[2], keys[3], keys[4], keys[5], 'e-wait'].filter(Boolean)); +}); + +test('re-requesting an already-live card just updates it, no extra slot', () => { + const keys = fill('f', MAX, 10); + assert.equal(requestAppSlot(keys[0], 5, false), true, 'existing card re-request is idempotent'); + assert.equal(requestAppSlot('f-far', 9999, false), false, 'still full after a re-request'); + release([...keys, 'f-far']); +}); diff --git a/frontend/src/shared/appWebviewBudget.ts b/frontend/src/shared/appWebviewBudget.ts new file mode 100644 index 00000000..299976fc --- /dev/null +++ b/frontend/src/shared/appWebviewBudget.ts @@ -0,0 +1,78 @@ +/** + * Hard ceiling on simultaneously-live app (View) webviews. An app preview is the heaviest surface on + * the canvas (a full renderer running a live frontend), and unlike browser cards these had NO global + * cap, so a pile of big app cards left in view could stack renderers until the whole app OOMs. This is + * admission control: a passive on-screen app goes live only if a slot is free, else it stays a + * placeholder; the cards closest to the viewport center win the scarce slots. A card the user is + * actively using (selected, interacting, agent-driven, tiled, fullscreen) is "pinned" and bypasses the + * cap, the same way a browser card's mustStayLive rule exempts it. Below the cap this is a no-op, so + * everyday behavior is unchanged; it only bites when too many previews want to be live at once. + */ +export const MAX_LIVE_APP_WEBVIEWS = 6; + +interface Slot { + priority: number; // squared distance from viewport center; smaller = closer = kept when slots are scarce + pinned: boolean; // actively used: never counted against the cap, never evicted +} + +const live = new Map(); +const listeners = new Set<() => void>(); + +// Deferred: an eviction firing inside one card's render must not synchronously poke another card's state. +function notify(): void { + queueMicrotask(() => { + for (const fn of [...listeners]) fn(); + }); +} + +function evictableLiveCount(): number { + let n = 0; + for (const s of live.values()) if (!s.pinned) n++; + return n; +} + +/** + * Whether this card may be live now. Idempotent (safe to call every pan/resize tick). A pinned card is + * always granted; an unpinned one is granted if a slot is free, or if it is closer to center than the + * farthest currently-live unpinned card, which it then evicts. + */ +export function requestAppSlot(key: string, priority: number, pinned: boolean): boolean { + const existing = live.get(key); + if (existing) { + existing.priority = priority; + existing.pinned = pinned; + return true; + } + if (pinned || evictableLiveCount() < MAX_LIVE_APP_WEBVIEWS) { + live.set(key, { priority, pinned }); + return true; + } + let worstKey: string | null = null; + let worstPriority = -Infinity; + for (const [k, s] of live) { + if (!s.pinned && s.priority > worstPriority) { + worstPriority = s.priority; + worstKey = k; + } + } + if (worstKey !== null && priority < worstPriority) { + live.delete(worstKey); + live.set(key, { priority, pinned }); + notify(); // the evicted card must re-evaluate and drop to a placeholder + return true; + } + return false; +} + +/** A card that suspends or unmounts MUST release, or its slot leaks and a capped card never wakes. */ +export function releaseAppSlot(key: string): void { + if (live.delete(key)) notify(); // a freed slot lets a capped card come alive +} + +/** Re-run a card's live/suspend decision whenever the budget changes (an eviction or a freed slot). */ +export function subscribeAppBudget(fn: () => void): () => void { + listeners.add(fn); + return () => { + listeners.delete(fn); + }; +}