From 940c1f2c902c02e456ace3cc24aa24c99752dcf6 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Wed, 22 Jul 2026 15:14:52 -0700 Subject: [PATCH] [eric] onboarding: reveal apps born as light click-to-open cards so the curtain lifts fully instant --- .../Dashboard/cards/DashboardViewCard.tsx | 43 +++++++++++++++---- .../hooks/lifecycle/useDashboardLifecycle.ts | 3 +- .../src/shared/state/dashboardLayoutSlice.ts | 15 ++++++- 3 files changed, 51 insertions(+), 10 deletions(-) diff --git a/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx b/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx index 9b18f12f..e5bd3ac3 100644 --- a/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx @@ -15,7 +15,7 @@ import HistoryRoundedIcon from '@mui/icons-material/HistoryRounded'; import AddIcon from '@mui/icons-material/Add'; import KeyboardArrowUpRounded from '@mui/icons-material/KeyboardArrowUpRounded'; import { Output, SERVE_BASE } from '@/shared/state/outputsSlice'; -import { setViewCardPosition, setViewCardSize, setActiveViewCardId, recordClosedCard, addViewCard, setTiledCard, clearTiledCard, toggleMinimizeCard } from '@/shared/state/dashboardLayoutSlice'; +import { setViewCardPosition, setViewCardSize, setActiveViewCardId, recordClosedCard, addViewCard, setTiledCard, clearTiledCard, toggleMinimizeCard, activateViewCardPreview } from '@/shared/state/dashboardLayoutSlice'; import { removeViewCardCleanly } from '@/shared/viewTeardown'; import WindowControls from './WindowControls'; import { useTiledStyle } from './tileZones'; @@ -148,6 +148,12 @@ const DashboardViewCard: React.FC = ({ const interactive = activeViewCardId === cardKey; const tileZone = useAppSelector((s) => s.dashboardLayout.tiledCards[cardKey]); const isMinimized = useAppSelector((s) => !!s.dashboardLayout.minimizedCards[cardKey]); + // Reveal-born apps stay a light "click to open" card until the first click, so the onboarding curtain + // lifts instantly instead of behind an in-frame live Vite boot. The click (selecting it) clears the flag. + const previewDeferred = useAppSelector((s) => !!s.dashboardLayout.viewCards[cardKey]?.preview_deferred); + useEffect(() => { + if (previewDeferred && (isSelected || interactive)) dispatch(activateViewCardPreview(cardKey)); + }, [previewDeferred, isSelected, interactive, cardKey, dispatch]); // Tiled geometry must track pan/zoom, but the camera lives outside React now; subscribe to the pan event ONLY while tiled and read the live getter. const [tileTick, setTileTick] = useState(0); useEffect(() => { @@ -165,14 +171,16 @@ const DashboardViewCard: React.FC = ({ // when it's being interacted with, driven by an agent, tiled, or selected; otherwise gated on being // on-screen at a readable size. A suspended card shows its last frame (or a calm placeholder). const alwaysLive = interactive || isSelected || isFullscreen || !!tileZone || showAgentGlow; - const [previewLive, setPreviewLive] = useState(true); + const [previewLive, setPreviewLive] = useState(!previewDeferred); const [suspendSnapshot, setSuspendSnapshot] = useState(null); - const previewLiveRef = useRef(true); + const previewLiveRef = useRef(!previewDeferred); const suspendTimerRef = useRef(null); useEffect(() => { const evaluate = (): void => { let want: boolean; - if (alwaysLive) { + if (previewDeferred) { + want = false; // reveal-parked: never boot until the first click clears the defer + } else if (alwaysLive) { want = true; } else { const now = getCanvasState(); @@ -196,6 +204,11 @@ const DashboardViewCard: React.FC = ({ previewLiveRef.current = true; setSuspendSnapshot(null); setPreviewLive(true); + } else if (previewDeferred) { + // Reveal-parked from birth: never booted, so no settle + no frame to capture, just stay light. + if (suspendTimerRef.current) { clearTimeout(suspendTimerRef.current); suspendTimerRef.current = null; } + previewLiveRef.current = false; + setPreviewLive(false); } else if (!suspendTimerRef.current) { suspendTimerRef.current = window.setTimeout(() => { suspendTimerRef.current = null; @@ -216,7 +229,7 @@ const DashboardViewCard: React.FC = ({ window.removeEventListener('resize', evaluate); if (suspendTimerRef.current) { clearTimeout(suspendTimerRef.current); suspendTimerRef.current = null; } }; - }, [alwaysLive, cardX, cardY, cardWidth, cardHeight, getCanvasState]); + }, [alwaysLive, previewDeferred, cardX, cardY, cardWidth, cardHeight, getCanvasState]); // Deselecting the card exits interact mode (click anywhere else on canvas). useEffect(() => { @@ -752,6 +765,7 @@ const DashboardViewCard: React.FC = ({ backendResult={backendResult} interactive={interactive} previewLive={previewLive} + previewDeferred={previewDeferred} suspendSnapshot={suspendSnapshot} onAppClicked={() => dispatch(setActiveViewCardId(cardKey))} onRuntimeLog={handleRuntimeLog} @@ -899,10 +913,11 @@ const DashboardOutputPreview: React.FC<{ backendResult: any; interactive: boolean; previewLive: boolean; + previewDeferred: boolean; suspendSnapshot: string | null; onAppClicked: () => void; onRuntimeLog?: (line: RuntimeLogLine) => void; -}> = ({ previewRef, output, cardKey, instance = 1, inputData, backendResult, interactive, previewLive, suspendSnapshot, onAppClicked, onRuntimeLog }) => { +}> = ({ previewRef, output, cardKey, instance = 1, inputData, backendResult, interactive, previewLive, previewDeferred, suspendSnapshot, onAppClicked, onRuntimeLog }) => { const tokens = useClaudeTokens(); const dispatch = useAppDispatch(); const workspaceId = output.workspace_id ?? null; @@ -1006,9 +1021,21 @@ const DashboardOutputPreview: React.FC<{ return ; } - // Parked (off-screen or too small to read): show the last frame (or a calm placeholder) instead of a - // live webview, so a zoomed-out canvas of apps doesn't run every Vite preview at once. Resumes on view. + // Parked: show the last frame (or a placeholder) instead of a live webview, so a zoomed-out canvas of + // apps doesn't run every Vite preview at once, and the reveal curtain lifts without an in-frame boot. if (!previewLive) { + // Reveal-deferred: an inviting "built for you, click to open" card (no snapshot yet, never booted). + if (previewDeferred) { + return ( + + + + + {output.name || 'Your app'} + Built for you, click to open + + ); + } return ( {suspendSnapshot ? ( diff --git a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts index 4e05773f..62215261 100644 --- a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts +++ b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts @@ -345,7 +345,8 @@ export function useDashboardLifecycle({ ? revealAppSpot(v3.revealAnchor) : null; if (revealSpot) { - dispatch(addViewCard({ outputId: output.id, expandedSessionIds, x: revealSpot.x, y: revealSpot.y })); + // Reveal: born as a light "click to open" card so the curtain lifts instantly, not behind a live Vite boot. + dispatch(addViewCard({ outputId: output.id, expandedSessionIds, x: revealSpot.x, y: revealSpot.y, previewDeferred: true })); } else { dispatch(addViewCard({ outputId: output.id, expandedSessionIds, parentSessionId: sid })); } diff --git a/frontend/src/shared/state/dashboardLayoutSlice.ts b/frontend/src/shared/state/dashboardLayoutSlice.ts index 34ec48bb..fc0b992c 100644 --- a/frontend/src/shared/state/dashboardLayoutSlice.ts +++ b/frontend/src/shared/state/dashboardLayoutSlice.ts @@ -52,6 +52,9 @@ export interface ViewCardPosition { height: number; zOrder: number; parent_session_id?: string | null; + // Reveal-born apps start as a light "built for you, click to open" card so the onboarding curtain + // lifts INSTANTLY instead of booting a live Vite preview in-frame. Cleared on first click -> boots. + preview_deferred?: boolean; } // Record key + card identity for a view card. The primary keeps the bare output_id so persisted layouts and every existing by-output lookup stay valid; secondaries append #N. @@ -802,8 +805,10 @@ const dashboardLayoutSlice = createSlice({ x?: number; y?: number; width?: number; height?: number; // Open ANOTHER independent instance of an already-open app instead of no-op'ing. newInstance?: boolean; + // Reveal-only: start as a light "click to open" card instead of booting the preview eagerly. + previewDeferred?: boolean; }>) { - const { outputId, expandedSessionIds, parentSessionId, x, y, width, height, newInstance } = action.payload; + const { outputId, expandedSessionIds, parentSessionId, x, y, width, height, newInstance, previewDeferred } = action.payload; let instance = 1; if (state.viewCards[outputId]) { if (!newInstance) return; @@ -839,10 +844,17 @@ const dashboardLayoutSlice = createSlice({ height: h, zOrder: state.nextZOrder++, parent_session_id: parentSessionId || null, + preview_deferred: previewDeferred || undefined, }; state.pendingFocusViewCardId = cardKey; }, + // First click on a reveal-parked app card: drop the defer so its live preview boots. + activateViewCardPreview(state, action: PayloadAction) { + const card = state.viewCards[action.payload]; + if (card && card.preview_deferred) card.preview_deferred = undefined; + }, + clearPendingFocusViewCardId(state) { state.pendingFocusViewCardId = null; }, @@ -1821,6 +1833,7 @@ export const { clearAllTiles, clearCardWindowState, clearPendingFocusBrowserId, + activateViewCardPreview, clearPendingFocusViewCardId, addWorkflowCard, setWorkflowCardPosition,