From bdbd0acb0dc0a8ddbefbc5db4e41047b8b17c418 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Fri, 26 Jun 2026 04:42:02 -0700 Subject: [PATCH] [eric] dashboard: revert orphan-prune to sync dispatch (async teardown was an over-reach, risked over-pruning imported cards) --- .../hooks/lifecycle/useDashboardLifecycle.ts | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts index 98963abd..b066fb75 100644 --- a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts +++ b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts @@ -14,13 +14,13 @@ import { addBrowserCard, addViewCard, resetLayout, + removeViewCard, clearPendingFocusBrowserId, clearPendingFocusWorkflowId, clearPendingFocusWorkflowsHub, type ViewCardPosition, } from '@/shared/state/dashboardLayoutSlice'; import { fetchOutputs, type Output } from '@/shared/state/outputsSlice'; -import { removeViewCardCleanly } from '@/shared/viewTeardown'; import { generateDashboardName } from '@/shared/state/dashboardsSlice'; import { fetchWorkflows, fetchAllRuns, fetchActiveRuns } from '@/shared/state/workflowsSlice'; import { fetchMissedRuns } from '@/shared/state/missedRunsSlice'; @@ -281,17 +281,11 @@ export function useDashboardLifecycle({ }, [sessions, layoutInitialized, dispatch, dashboardId, expandedSessionIds]); // Prune orphan view cards whose underlying output was deleted (e.g. via the Views page). Without this, the layout entry persists in the minimap and contentBounds even though DashboardViewCard renders nothing. Gated on outputsRefetched (THIS open's fresh fetch), NOT the sticky global outputsLoaded: on a freshly-imported dashboard the global flag is already true from a prior dashboard, so the old gate pruned the just-imported app card against a stale apps list and the debounced save persisted the wipe. - const pruningRef = useRef(false); useEffect(() => { - if (!layoutInitialized || !outputsRefetched || pruningRef.current) return; - const orphans = Object.keys(viewCards).filter((outputId) => !outputs[outputId]); - if (!orphans.length) return; - pruningRef.current = true; - // Serialize the prune (one quiesce at a time) so deleting a couple of large apps via the Views page can't rip several live webview GPU surfaces out in one frame; the ref stops this effect's own dispatches from spawning overlapping loops. - void (async () => { - try { for (const outputId of orphans) await removeViewCardCleanly(outputId, dispatch); } - finally { pruningRef.current = false; } - })(); + if (!layoutInitialized || !outputsRefetched) return; + for (const outputId of Object.keys(viewCards)) { + if (!outputs[outputId]) dispatch(removeViewCard(outputId)); + } }, [layoutInitialized, outputsRefetched, viewCards, outputs, dispatch]); // On first load after outputs settle, snapshot every existing Output id as "already accounted for." Any output that ARRIVES later (typically the agent:output_upserted WS broadcast the backend fires the instant a view-builder session is seeded, at session start) whose session_id points at a view-builder chat on this dashboard gets a view card dropped on the canvas right away. Per-mount tracked so a manual close after auto-open stays closed. Prior approach keyed off a pending-set populated inside launchAndSendFirstMessage.then(): the WS upsert won the race and the effect saw an empty set, so the card didn't pop until the session-end meta-sync re-broadcast.