[eric] onboarding: reveal apps born as light click-to-open cards so the curtain lifts fully instant

This commit is contained in:
ciregenz
2026-07-22 15:14:52 -07:00
parent be457d69cd
commit 940c1f2c90
3 changed files with 51 additions and 10 deletions
@@ -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<Props> = ({
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<Props> = ({
// 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<string | null>(null);
const previewLiveRef = useRef(true);
const previewLiveRef = useRef(!previewDeferred);
const suspendTimerRef = useRef<number | null>(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<Props> = ({
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<Props> = ({
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<Props> = ({
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 <BootingBody />;
}
// 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 (
<Box sx={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 1.25, bgcolor: tokens.bg.surface, cursor: 'pointer' }}>
<Box sx={{ width: 44, height: 44, borderRadius: '12px', display: 'flex', alignItems: 'center', justifyContent: 'center', bgcolor: `${tokens.accent.primary}1F`, color: tokens.accent.primary }}>
<VisibilityRoundedIcon sx={{ fontSize: 22 }} />
</Box>
<Typography sx={{ color: tokens.text.primary, fontSize: '0.92rem', fontWeight: 600 }}>{output.name || 'Your app'}</Typography>
<Typography sx={{ color: tokens.text.muted, fontSize: '0.78rem' }}>Built for you, click to open</Typography>
</Box>
);
}
return (
<Box sx={{ width: '100%', height: '100%', position: 'relative', bgcolor: tokens.bg.surface, overflow: 'hidden' }}>
{suspendSnapshot ? (
@@ -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 }));
}
@@ -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<string>) {
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,