[aidan] ui/ux: when building app window appears immediately

This commit is contained in:
abccodes
2026-06-14 05:29:27 -07:00
parent acce52e1bf
commit 00b44274cf
4 changed files with 75 additions and 39 deletions
@@ -1,6 +1,7 @@
import React, { useState, useRef, useCallback, useEffect } from 'react';
import { createPortal } from 'react-dom';
import Box from '@mui/material/Box';
import Fade from '@mui/material/Fade';
import Typography from '@mui/material/Typography';
import IconButton from '@mui/material/IconButton';
import Tooltip from '@mui/material/Tooltip';
@@ -10,7 +11,7 @@ import CloseIcon from '@mui/icons-material/Close';
import GridViewRoundedIcon from '@mui/icons-material/GridViewRounded';
import { Output, SERVE_BASE } from '@/shared/state/outputsSlice';
import { setViewCardPosition, setViewCardSize, removeViewCard } from '@/shared/state/dashboardLayoutSlice';
import { useAppDispatch } from '@/shared/hooks';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { API_BASE, getAuthToken } from '@/shared/config';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import ViewPreview, { ViewPreviewHandle } from '@/app/pages/Views/ViewPreview';
@@ -407,6 +408,7 @@ const DashboardViewCard: React.FC<Props> = ({
inputData={inputData}
backendResult={backendResult}
/>
<BuildingOverlay sessionId={output.session_id ?? null} />
</Box>
{/* Resize handles */}
@@ -481,6 +483,51 @@ const DashboardViewCard: React.FC<Props> = ({
export default React.memo(DashboardViewCard);
// Calm overlay shown while the App Builder chat that owns this output is
// actively editing it. Hides whatever transient half-broken state the agent
// might be writing through (a missing import, a syntax error mid-keystroke)
// so the user sees "Building..." instead of an error iframe. Fades in/out.
const BuildingOverlay: React.FC<{ sessionId: string | null }> = ({ sessionId }) => {
const c = useClaudeTokens();
const status = useAppSelector((s) => (sessionId ? s.agents.sessions[sessionId]?.status : undefined));
const isBuilding = status === 'running' || status === 'waiting_approval';
return (
<Fade in={isBuilding} timeout={{ enter: 200, exit: 220 }} unmountOnExit>
<Box
sx={{
position: 'absolute',
inset: 0,
zIndex: 11,
bgcolor: c.bg.surface,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 1.25,
// Block pointer events to the iframe behind so user can't click into the half-built app.
pointerEvents: 'auto',
}}
>
<Box
sx={{
width: 8,
height: 8,
borderRadius: '50%',
bgcolor: c.accent.primary,
animation: 'view-card-building-pulse 1.2s ease-in-out infinite',
'@keyframes view-card-building-pulse': {
'0%, 100%': { opacity: 0.35, transform: 'scale(0.85)' },
'50%': { opacity: 1, transform: 'scale(1)' },
},
}}
/>
<Typography sx={{ color: c.text.secondary, fontSize: '0.85rem', fontWeight: 500 }}>
Building
</Typography>
</Box>
</Fade>
);
};
// Old-mode outputs render the legacy serve URL; new-mode webapp_template outputs attach to a runtime and point the webview at Vite once frontend_url arrives.
const DashboardOutputPreview: React.FC<{
previewRef: React.Ref<ViewPreviewHandle>;
@@ -38,7 +38,6 @@ interface UseAgentSpawnArgs {
setToolbarOpen: Dispatch<SetStateAction<boolean>>;
setAutoFocusSessionId: Dispatch<SetStateAction<string | null>>;
setPendingSelectSessionId: Dispatch<SetStateAction<string | null>>;
pendingViewBuilderSessionsRef: RefObject<Set<string>>;
}
export function useAgentSpawn({
@@ -55,7 +54,6 @@ export function useAgentSpawn({
setToolbarOpen,
setAutoFocusSessionId,
setPendingSelectSessionId,
pendingViewBuilderSessionsRef,
}: UseAgentSpawnArgs) {
const dispatch = useAppDispatch();
@@ -157,12 +155,6 @@ export function useAgentSpawn({
).then((action) => {
if (launchAndSendFirstMessage.fulfilled.match(action)) {
const realId = action.payload.session.id;
// App Builder chats expect a view card to pop in next to the chat
// once the backend seeds its Output row. Register the session id
// so useDashboardLifecycle's output watcher can do the auto-open.
if (mode === 'view-builder') {
pendingViewBuilderSessionsRef.current?.add(realId);
}
dispatch(generateTitle({ sessionId: realId, prompt }));
if (selectedBrowserIds?.length) {
dispatch(setGlowingBrowserCards({ browserIds: selectedBrowserIds, sessionId: realId, label: 'Use Browser' }));
@@ -1,4 +1,4 @@
import { useEffect, useRef, type MutableRefObject, type RefObject } from 'react';
import { useEffect, useRef, type MutableRefObject } from 'react';
import { report } from '@/shared/serviceClient';
import { store } from '@/shared/state/store';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
@@ -40,7 +40,6 @@ interface UseDashboardLifecycleArgs {
handleHighlightCard: (cardId: string) => void;
hasFittedRef: MutableRefObject<boolean>;
restoredExpandedRef: MutableRefObject<boolean>;
pendingViewBuilderSessionsRef: RefObject<Set<string>>;
}
export function useDashboardLifecycle({
@@ -57,7 +56,6 @@ export function useDashboardLifecycle({
handleHighlightCard,
hasFittedRef,
restoredExpandedRef,
pendingViewBuilderSessionsRef,
}: UseDashboardLifecycleArgs) {
const dispatch = useAppDispatch();
const pendingBrowserUrl = useAppSelector((state) => state.tempState.pendingBrowserUrl);
@@ -256,27 +254,35 @@ export function useDashboardLifecycle({
}
}, [layoutInitialized, outputsLoaded, viewCards, outputs, dispatch]);
// App Builder auto-open: when a view-builder session launched on this
// dashboard, useAgentSpawn parks its id in pendingViewBuilderSessionsRef.
// The backend then seeds an Output row and broadcasts agent:output_upserted;
// when that lands in outputsSlice we drop a ViewCard next to the chat so the
// user sees the app appear without a manual "open" step. Per-mount tracked
// (not redux) so a manual close after auto-open stays closed.
// 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.
const autoOpenedOutputsRef = useRef<Set<string>>(new Set());
const outputsSnapshottedRef = useRef<boolean>(false);
useEffect(() => {
if (!layoutInitialized) return;
const pending = pendingViewBuilderSessionsRef.current;
if (!pending || pending.size === 0) return;
if (!layoutInitialized || !outputsLoaded) return;
if (!outputsSnapshottedRef.current) {
for (const oid of Object.keys(outputs)) autoOpenedOutputsRef.current.add(oid);
outputsSnapshottedRef.current = true;
return;
}
for (const output of Object.values(outputs)) {
if (autoOpenedOutputsRef.current.has(output.id)) continue;
const sid = output.session_id;
if (!sid || !pending.has(sid)) continue;
if (viewCards[output.id]) {
pending.delete(sid);
continue;
}
// Let addViewCard pick a collision-free grid cell; the auto-fit below
// pans/zooms to show the chat + new view card together regardless.
if (!sid) continue;
const sess = sessions[sid];
if (!sess || sess.mode !== 'view-builder') continue;
if (sess.dashboard_id !== dashboardId) continue;
autoOpenedOutputsRef.current.add(output.id);
if (viewCards[output.id]) continue;
dispatch(addViewCard({ outputId: output.id, expandedSessionIds }));
pending.delete(sid);
const outputId = output.id;
setTimeout(() => {
const vc = store.getState().dashboardLayout.viewCards[outputId];
@@ -288,7 +294,7 @@ export function useDashboardLifecycle({
handleHighlightCard(outputId);
}, 200);
}
}, [layoutInitialized, outputs, viewCards, expandedSessionIds, dispatch, canvasActions, handleHighlightCard, pendingViewBuilderSessionsRef]);
}, [layoutInitialized, outputsLoaded, outputs, sessions, viewCards, dashboardId, expandedSessionIds, dispatch, canvasActions, handleHighlightCard]);
const namedOnFirstMessageRef = useRef<string | null>(null);
useEffect(() => {
@@ -113,13 +113,6 @@ export function useDashboardController(dashboardId: string, isActive: boolean) {
contentRef: canvas.contentRef,
});
// Session ids that just launched in App Builder ('view-builder') mode and
// are still awaiting their backend-seeded Output row. useAgentSpawn writes
// here; useDashboardLifecycle consumes it to auto-open the view card the
// moment the output upsert arrives. Per-mount ref (not redux) so a manual
// close after auto-open stays closed.
const pendingViewBuilderSessionsRef = useRef<Set<string>>(new Set());
useDashboardLifecycle({
isActive,
dashboardId,
@@ -134,7 +127,6 @@ export function useDashboardController(dashboardId: string, isActive: boolean) {
handleHighlightCard,
hasFittedRef,
restoredExpandedRef,
pendingViewBuilderSessionsRef,
});
// ---- Auto-reveal / collapse / unreveal sub-agent cards ----
@@ -229,7 +221,6 @@ export function useDashboardController(dashboardId: string, isActive: boolean) {
setToolbarOpen,
setAutoFocusSessionId,
setPendingSelectSessionId,
pendingViewBuilderSessionsRef,
});
const {