[eric] App Builder polish + onboarding fix

This commit is contained in:
ciregenz
2026-05-13 00:24:51 -07:00
parent 91508c87b1
commit 1b0e6c35eb
14 changed files with 862 additions and 136 deletions
+41
View File
@@ -387,6 +387,47 @@ Common deps already in the template:
---
## Verify before declaring done — runtime errors are silent in the preview
The preview iframe is wrapped in an ErrorBoundary that surfaces React
runtime errors as a visible red error card AND mirrors the error into
the Terminal pane as a `[FRONTEND]` line tagged `[openswarm:app-error]`.
After substantial edits — especially anything that touches imports,
hooks, or React state — **always check the most recent `[FRONTEND]`
lines in your Terminal output before saying "done"**. If you see one,
fix it before claiming the app is ready.
The three most common ways agent edits crash a React preview:
1. **Lost import after MultiEdit / Edit.** When you delete or rename a
symbol's usage inside a file but don't update the corresponding
`import` line, the file references an undefined name at runtime.
Symptom in Terminal: `[FRONTEND] ReferenceError: HomeIcon is not defined`
or similar. Always re-read the imports block of any file you
edited and confirm every imported name is still used and every
used name is still imported.
2. **`Invalid hook call` from a duplicate React copy.** Running
`npm install react` or `npm install some-package-that-bundles-react`
inside the workspace adds a second React to `node_modules`, and the
two copies' hook dispatchers can't see each other → every `useState`
call throws on mount. The template's `node_modules` is symlinked to
a shared warm cache; only add packages whose `peerDependencies`
declare a non-bundled React. If you see `Cannot read properties of null (reading 'useState')`, suspect a duplicate React first.
3. **Hooks called outside a component body or after a conditional
return.** `useState`/`useEffect`/`useMemo` must run in the same
order on every render. Adding an `if (...) return null` BEFORE a
hook, or calling a hook inside a callback, breaks the rule. The
ErrorBoundary will print the offending component name in the
surfaced stack — start there.
When in doubt, read the file you just edited end-to-end one more time.
Re-reading is cheap; sending a half-broken preview back to the user is
not.
---
## Quick start checklist
When making a new app from scratch:
@@ -0,0 +1,171 @@
import React from 'react';
interface ErrorBoundaryProps {
children: React.ReactNode;
}
interface ErrorBoundaryState {
error: Error | null;
errorInfo: React.ErrorInfo | null;
}
/**
* Visible error surface for the App Builder template.
*
* When the agent's edits introduce a runtime React error (most commonly:
* a missing import after a multi-edit refactor, an `Invalid hook call`
* from duplicate React copies / hooks-called-conditionally, or a typo
* in JSX), React unmounts the whole tree and the iframe goes black —
* the user just sees an empty preview pane and has no idea what
* happened. This boundary catches those errors, renders a readable
* error card in their place, AND mirrors the error up to the OpenSwarm
* host (via window.parent.postMessage + console.error, both of which
* the webview-preload bridge already forwards) so the App Builder
* agent's `post_tool_hook` can see what went wrong on its next turn
* and self-heal without the user having to copy-paste the stack.
*
* Kept as a single small class component with no MUI / theme imports
* so it itself can't crash the boundary — it's the last line of
* defense and has to be import-minimal on purpose.
*/
class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
state: ErrorBoundaryState = { error: null, errorInfo: null };
static getDerivedStateFromError(error: Error): Partial<ErrorBoundaryState> {
return { error };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void {
this.setState({ errorInfo });
// Two channels so the OpenSwarm host's webview-preload bridge can
// pick this up regardless of which one it taps:
// 1. console.error — forwarded as a `[FRONTEND]` line into the
// App Builder's Terminal pane, which the agent's
// drain_errors_for_path hook reads on its next tool call.
// 2. postMessage — host-side listeners (if/when added) can read
// the structured payload without parsing console output.
// eslint-disable-next-line no-console
console.error(
'[openswarm:app-error]',
error?.message ?? String(error),
errorInfo?.componentStack ?? '',
);
try {
window.parent.postMessage(
{
type: 'openswarm:app-error',
message: error?.message ?? String(error),
stack: error?.stack,
componentStack: errorInfo?.componentStack,
},
'*',
);
} catch {
/* postMessage to opaque parent can throw — best-effort only */
}
}
handleReload = (): void => {
this.setState({ error: null, errorInfo: null });
window.location.reload();
};
render(): React.ReactNode {
const { error, errorInfo } = this.state;
if (!error) return this.props.children;
// Inline-styled so even a busted theme context can't take this
// surface down with it.
const wrapStyle: React.CSSProperties = {
position: 'fixed',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: 24,
background: '#1a1918',
color: '#FAF9F5',
fontFamily: 'ui-sans-serif, system-ui, -apple-system, sans-serif',
zIndex: 99999,
overflow: 'auto',
};
const cardStyle: React.CSSProperties = {
maxWidth: 640,
width: '100%',
padding: 24,
borderRadius: 14,
background: '#262624',
border: '1px solid rgba(196,99,58,0.4)',
boxShadow: '0 12px 40px rgba(0,0,0,0.3)',
};
const headingStyle: React.CSSProperties = {
margin: 0,
fontSize: 16,
fontWeight: 600,
color: '#c4633a',
marginBottom: 6,
letterSpacing: '-0.01em',
};
const subStyle: React.CSSProperties = {
margin: 0,
fontSize: 13,
color: '#9C9A92',
marginBottom: 16,
lineHeight: 1.5,
};
const codeStyle: React.CSSProperties = {
display: 'block',
fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace',
fontSize: 12,
lineHeight: 1.5,
background: '#1f1e1b',
color: '#FAF9F5',
padding: 12,
borderRadius: 8,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
maxHeight: 220,
overflow: 'auto',
marginBottom: 12,
};
const buttonStyle: React.CSSProperties = {
appearance: 'none',
border: 'none',
background: '#c4633a',
color: '#FAF9F5',
fontSize: 13,
fontWeight: 600,
padding: '8px 16px',
borderRadius: 999,
cursor: 'pointer',
fontFamily: 'inherit',
};
return (
<div style={wrapStyle}>
<div style={cardStyle}>
<h2 style={headingStyle}>This app hit a snag.</h2>
<p style={subStyle}>
The agent's most recent edit introduced an error that prevents
the preview from rendering. Ask the agent to fix it — the
details below are already piped to its Terminal so it can
see them on the next turn.
</p>
<code style={codeStyle}>
{error.message || String(error)}
</code>
{errorInfo?.componentStack && (
<code style={{ ...codeStyle, fontSize: 11, color: '#9C9A92' }}>
{errorInfo.componentStack.trim()}
</code>
)}
<button type="button" style={buttonStyle} onClick={this.handleReload}>
Reload preview
</button>
</div>
</div>
);
}
}
export default ErrorBoundary;
@@ -13,6 +13,7 @@ import LightModeIcon from '@mui/icons-material/LightMode';
import DarkModeIcon from '@mui/icons-material/DarkMode';
import { NavLink, useLocation } from 'react-router-dom';
import { useClaudeTokens, useThemeMode } from '@/shared/styles/ThemeContext';
import logoUrl from '@/assets/logo.png';
const NAV_ITEMS = [
{ path: '/', label: 'Home', icon: HomeIcon },
@@ -43,8 +44,10 @@ const Sidebar: React.FC<SidebarProps> = ({ collapsed, onToggle }) => {
height: '100vh',
display: 'flex',
flexDirection: 'column',
// Soft tonal separation instead of a hard borderRight — Claude
// Design uses background-color steps rather than 1px lines for
// pane boundaries, which reads as airy rather than fenced-off.
bgcolor: c.bg.secondary,
borderRight: `1px solid ${c.border.subtle}`,
transition: c.transition,
overflow: 'hidden',
}}
@@ -54,29 +57,42 @@ const Sidebar: React.FC<SidebarProps> = ({ collapsed, onToggle }) => {
sx={{
display: 'flex',
alignItems: 'center',
gap: 1.5,
px: collapsed ? 0 : 2.5,
py: 2.5,
gap: 1.75,
px: collapsed ? 0 : 3,
// Looser vertical breathing — was py:2.5 → 3.5 (~+40%). The
// sidebar logo lockup is the first thing the eye lands on;
// tight padding reads as cramped.
py: 3.5,
justifyContent: collapsed ? 'center' : 'flex-start',
minHeight: 64,
minHeight: 72,
cursor: 'pointer',
userSelect: 'none',
}}
>
<Box
component="img"
src="/logo.png"
// Imported as an ES module so vite bundles the asset into the
// JS chunk graph — `/logo.png` from `public/` was getting
// 404'd during the cold-start window (vite hadn't indexed the
// public dir yet), which produced visible broken-image
// placeholders in the first ~1 s of every app load.
src={logoUrl}
alt="OpenSwarm"
sx={{ width: 28, height: 28, objectFit: 'contain', flexShrink: 0 }}
sx={{ width: 26, height: 26, objectFit: 'contain', flexShrink: 0 }}
/>
{!collapsed && (
<Typography
sx={{
fontWeight: 700,
fontSize: '0.95rem',
fontWeight: 600,
fontSize: '1rem',
color: c.text.primary,
fontFamily: c.font.serif,
letterSpacing: '-0.01em',
// Unified sans across the whole template — was a mix of
// FONT_SERIF (which falls back to Times on systems without
// "Anthropic Sans") and FONT_SANS, which read as two
// different apps stitched together. Inherit the body
// font instead.
fontFamily: 'inherit',
letterSpacing: '-0.015em',
whiteSpace: 'nowrap',
}}
>
@@ -85,7 +101,7 @@ const Sidebar: React.FC<SidebarProps> = ({ collapsed, onToggle }) => {
)}
</Box>
<List sx={{ flex: 1, px: collapsed ? 1 : 1.5, pt: 0.5 }}>
<List sx={{ flex: 1, px: collapsed ? 1 : 2, pt: 1, gap: 0.5 }}>
{NAV_ITEMS.map(({ path, label, icon: Icon }) => {
const isActive =
path === '/' ? location.pathname === '/' : location.pathname.startsWith(path);
@@ -96,24 +112,32 @@ const Sidebar: React.FC<SidebarProps> = ({ collapsed, onToggle }) => {
component={NavLink}
to={path}
sx={{
borderRadius: 2,
// Fully pill-shaped — Claude Design's nav items are
// capsules, not 4-8 px rounded rectangles. 999 px clamps
// to the natural height = perfect pill.
borderRadius: 999,
mb: 0.5,
py: 0.75,
px: collapsed ? 0 : undefined,
py: 1,
px: collapsed ? 0 : 1.75,
justifyContent: collapsed ? 'center' : 'flex-start',
bgcolor: isActive ? `${c.accent.primary}0F` : 'transparent',
'&:hover': { bgcolor: `${c.accent.primary}08` },
// Background-fill active state instead of an underline
// bar. The fill uses a richer accent-tinted bg so the
// pill reads as the focal point of the sidebar.
bgcolor: isActive ? `${c.accent.primary}1A` : 'transparent',
'&:hover': {
bgcolor: isActive ? `${c.accent.primary}1A` : `${c.text.primary}08`,
},
transition: c.transition,
}}
>
<ListItemIcon
sx={{
color: isActive ? c.accent.primary : c.text.tertiary,
minWidth: collapsed ? 'auto' : 36,
minWidth: collapsed ? 'auto' : 32,
justifyContent: 'center',
}}
>
<Icon sx={{ fontSize: 20 }} />
<Icon sx={{ fontSize: 19 }} />
</ListItemIcon>
{!collapsed && (
<ListItemText
@@ -121,9 +145,9 @@ const Sidebar: React.FC<SidebarProps> = ({ collapsed, onToggle }) => {
sx={{
'& .MuiListItemText-primary': {
color: isActive ? c.text.primary : c.text.muted,
fontSize: '0.875rem',
fontWeight: isActive ? 500 : 400,
fontFamily: c.font.serif,
fontSize: '0.9rem',
fontWeight: isActive ? 600 : 450,
fontFamily: 'inherit',
whiteSpace: 'nowrap',
},
}}
@@ -146,7 +170,8 @@ const Sidebar: React.FC<SidebarProps> = ({ collapsed, onToggle }) => {
sx={{
px: collapsed ? 1 : 2.5,
py: 2,
borderTop: `1px solid ${c.border.subtle}`,
// Drop the hard borderTop here too — let the bg-color step
// between the nav list and this footer do the work.
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-start',
@@ -158,7 +183,8 @@ const Sidebar: React.FC<SidebarProps> = ({ collapsed, onToggle }) => {
size="small"
sx={{
color: c.text.tertiary,
'&:hover': { color: c.accent.primary, bgcolor: `${c.accent.primary}0A` },
borderRadius: 999,
'&:hover': { color: c.accent.primary, bgcolor: `${c.text.primary}08` },
transition: c.transition,
}}
>
@@ -172,9 +198,9 @@ const Sidebar: React.FC<SidebarProps> = ({ collapsed, onToggle }) => {
{!collapsed && (
<Typography
sx={{
fontSize: '0.75rem',
fontSize: '0.78rem',
color: c.text.ghost,
fontFamily: c.font.mono,
fontFamily: 'inherit',
ml: 1,
}}
>
Binary file not shown.

After

Width:  |  Height:  |  Size: 320 KiB

@@ -1,12 +1,23 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import Main from './app/Main';
import ErrorBoundary from './app/components/ErrorBoundary';
console.log('[App] Bootstrapping React app');
const rootEl = document.getElementById('root');
if (!rootEl) {
console.error('[App] FATAL: #root element not found in DOM');
} else {
createRoot(rootEl).render(<Main />);
// Wrap Main in an ErrorBoundary so any runtime crash from agent
// edits (missing imports, hook-rules violations, etc.) shows a
// readable error card in the preview pane instead of unmounting
// to a blank screen. The boundary also forwards the error via
// console.error + postMessage so the agent sees it on its next
// turn.
createRoot(rootEl).render(
<ErrorBoundary>
<Main />
</ErrorBoundary>,
);
console.log('[App] React root mounted');
}
@@ -3,6 +3,7 @@ import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import { motion } from 'framer-motion';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import logoUrl from '@/assets/logo.png';
const Home: React.FC = () => {
const c = useClaudeTokens();
@@ -19,37 +20,52 @@ const Home: React.FC = () => {
}}
>
<motion.div
initial={{ opacity: 0, y: 24 }}
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ type: 'spring', stiffness: 380, damping: 30, mass: 0.8 }}
>
<Box sx={{ maxWidth: 440, width: '100%', textAlign: 'center' }}>
{/* Smaller, quieter mascot — was 48 px and the loudest thing
on the screen. Claude Design's empty states let typography
lead with a tiny visual accent. Asset is imported (vite
bundles it) instead of `/logo.png` from public/ which
404'd during the cold-start window. */}
<Box
component="img"
src="/logo.png"
alt="OpenSwarm"
sx={{ width: 48, height: 48, objectFit: 'contain', mb: 1 }}
src={logoUrl}
alt=""
sx={{
width: 32,
height: 32,
objectFit: 'contain',
mb: 2.5,
opacity: 0.85,
}}
/>
<Typography
variant="h6"
sx={{
fontWeight: 700,
fontWeight: 600,
fontSize: '1.4rem',
color: c.text.primary,
mb: 0.5,
fontFamily: c.font.serif,
letterSpacing: '-0.01em',
mb: 1,
// Inherit body sans — keeps the empty state consistent
// with the rest of the template instead of jumping to a
// serif headline.
fontFamily: 'inherit',
letterSpacing: '-0.02em',
}}
>
OpenSwarm
</Typography>
<Typography
sx={{
fontSize: '0.875rem',
color: c.text.secondary,
fontFamily: c.font.serif,
fontSize: '0.9rem',
color: c.text.tertiary,
fontFamily: 'inherit',
lineHeight: 1.55,
}}
>
Web app template ready to build.
Web app template, ready to build.
</Typography>
</Box>
</motion.div>
@@ -1,10 +1,74 @@
import React, { createContext, useContext, useMemo, useState, useCallback } from 'react';
import React, { createContext, useContext, useEffect, useMemo, useState, useCallback, useRef } from 'react';
import { ThemeProvider, createTheme } from '@mui/material/styles';
import CssBaseline from '@mui/material/CssBaseline';
type Mode = 'light' | 'dark';
const FONT_SERIF = '"Anthropic Sans", ui-serif, Georgia, Cambria, "Times New Roman", Times, serif';
// ---- Cross-app theme persistence helpers ------------------------------------
//
// The template ships with a Light/Dark toggle but each app workspace runs
// from its own vite dev-server port, so plain localStorage won't carry the
// user's choice over to a NEW app they spin up later. To make the override
// sticky across every App Builder workspace we:
//
// 1. Default to the OS appearance (`prefers-color-scheme: dark`) on
// first mount — synchronous, no flash.
// 2. Persist user toggles to localStorage for instant re-render on the
// same app, AND to OpenSwarm's /api/settings.app_template_theme_override
// so future apps see it on load.
// 3. After mount, async-fetch /api/settings; if the override there
// differs from the synchronous default, switch to it. Brief style
// flicker is preferable to either (a) an SSR injection (these are
// vite SPAs) or (b) blocking initial render on a network call.
//
// The auth token rides in the URL (the template is loaded with
// `?token=<bearer>` by OpenSwarm's webview preload), so the fetch can hit
// localhost:8324 cross-origin via the host's CORS allow-list.
const LOCAL_STORAGE_KEY = 'openswarm-app-theme-override';
const OPENSWARM_BACKEND = 'http://localhost:8324';
function readUrlToken(): string {
try {
return new URLSearchParams(window.location.search).get('token') ?? '';
} catch {
return '';
}
}
function readLocalStorageOverride(): Mode | null {
try {
const v = window.localStorage.getItem(LOCAL_STORAGE_KEY);
return v === 'light' || v === 'dark' ? v : null;
} catch {
return null;
}
}
function detectSystemPreference(): Mode {
try {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
} catch {
return 'light';
}
}
function getInitialMode(): Mode {
return readLocalStorageOverride() ?? detectSystemPreference();
}
// Clean unified sans for body + headings. The previous FONT_SERIF stack
// fell back to Times on systems without "Anthropic Sans" (which is most
// systems), so headings rendered as Times and body as system sans —
// reading as two different apps stitched together. One sans family
// everywhere is the single biggest "feels designed vs feels assembled"
// switch.
const FONT_SANS = 'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "SF Pro Text", "Inter", "Helvetica Neue", Arial, sans-serif';
// Kept under its old name so any agent-written code that already
// references `c.font.serif` for an intentional display flourish still
// resolves — points at the same sans stack so the visual result is
// consistent regardless of which token a caller picked.
const FONT_SERIF = FONT_SANS;
const FONT_MONO = 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace';
const lightTokens = {
@@ -113,12 +177,20 @@ interface ThemeModeContextValue {
toggleMode: () => void;
}
// Default context uses the system-preference / localStorage-override at
// module load time. Without this, the brief window before
// ClaudeThemeProvider mounts (or any out-of-tree consumer of these
// contexts) would render in hardcoded light and flash to the resolved
// theme on the next commit. `getInitialMode` is synchronous and reads
// only DOM/localStorage so it's safe to call at module init.
const _bootMode: Mode = typeof window !== 'undefined' ? getInitialMode() : 'light';
const ThemeModeContext = createContext<ThemeModeContextValue>({
mode: 'light',
mode: _bootMode,
toggleMode: () => {},
});
const TokensContext = createContext<ClaudeTokens>(buildTokens('light'));
const TokensContext = createContext<ClaudeTokens>(buildTokens(_bootMode));
export function useThemeMode() {
return useContext(ThemeModeContext);
@@ -133,12 +205,91 @@ interface ClaudeThemeProviderProps {
}
const ClaudeThemeProvider: React.FC<ClaudeThemeProviderProps> = ({ children }) => {
const [mode, setMode] = useState<Mode>('light');
const [mode, setMode] = useState<Mode>(getInitialMode);
// True once the user has explicitly toggled in this session. While
// false, the system-preference media-query listener is allowed to
// override `mode`; once the user toggles even once we stop chasing
// the system and respect their choice.
const userOverrideRef = useRef<boolean>(readLocalStorageOverride() !== null);
// (1) After mount, ask OpenSwarm whether a prior app already set a
// cross-app override. If so AND the user hasn't already toggled in
// this session AND it differs from current state, adopt it.
useEffect(() => {
const token = readUrlToken();
if (!token) return; // No token = can't auth = skip silently.
let cancelled = false;
(async () => {
try {
// Use the dedicated endpoint instead of /api/settings — the
// generic settings PUT takes a FULL AppSettings body which
// defaults every unset field (api keys, subscription tokens,
// etc.), so PUTting just `{ app_template_theme_override }`
// there logs the user out. The dedicated endpoint merges.
const res = await fetch(`${OPENSWARM_BACKEND}/api/settings/app-theme-override`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok || cancelled) return;
const data = await res.json();
const remote = data?.mode;
if (remote !== 'light' && remote !== 'dark') return;
// localStorage wins for this app — the user toggled here, that's
// the latest signal. Otherwise adopt the remote preference.
if (readLocalStorageOverride() !== null) return;
userOverrideRef.current = true;
setMode(remote);
} catch {
/* offline / cross-origin blocked / etc — keep the system default */
}
})();
return () => { cancelled = true; };
}, []);
// (2) If the user hasn't pinned a choice yet, follow the OS as it
// changes (e.g. macOS auto light-at-day-dark-at-night).
useEffect(() => {
if (userOverrideRef.current) return;
let mq: MediaQueryList;
try {
mq = window.matchMedia('(prefers-color-scheme: dark)');
} catch {
return;
}
const onChange = () => {
if (userOverrideRef.current) return;
setMode(mq.matches ? 'dark' : 'light');
};
mq.addEventListener?.('change', onChange);
return () => {
try { mq.removeEventListener?.('change', onChange); } catch {}
};
}, []);
const toggleMode = useCallback(() => {
setMode((prev) => {
const next = prev === 'light' ? 'dark' : 'light';
console.log(`[Theme] Toggled ${prev}${next}`);
const next: Mode = prev === 'light' ? 'dark' : 'light';
userOverrideRef.current = true;
// (a) Local fast path so subsequent reloads of THIS app don't
// flash the wrong theme.
try {
window.localStorage.setItem(LOCAL_STORAGE_KEY, next);
} catch { /* private mode etc. — fine, the remote PUT still carries it */ }
// (b) Cross-app persistence: push to OpenSwarm's dedicated
// theme-override endpoint (NOT the generic /api/settings PUT,
// which expects a full AppSettings body and would default every
// unspecified field — wiping api keys / subscription tokens and
// popping the SignInGate). Fire-and-forget — best effort.
const token = readUrlToken();
if (token) {
fetch(`${OPENSWARM_BACKEND}/api/settings/app-theme-override`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ mode: next }),
}).catch(() => { /* offline / blocked — local override still holds */ });
}
return next;
});
}, []);
@@ -150,7 +301,7 @@ const ClaudeThemeProvider: React.FC<ClaudeThemeProviderProps> = ({ children }) =
createTheme({
palette: { mode },
typography: {
fontFamily: FONT_SERIF,
fontFamily: FONT_SANS,
button: { textTransform: 'none' as const },
},
components: {
+10
View File
@@ -40,6 +40,16 @@ class AppSettings(BaseModel):
default_thinking_level: Literal["off", "low", "medium", "high", "auto"] = "auto"
zoom_sensitivity: float = 50.0
theme: str = "dark"
# App Builder workspaces seed a React template that ships with its own
# theme toggle ("Light" / "Dark" at the bottom of the sidebar). By
# default the template should follow the user's OS appearance; once
# the user explicitly toggles it inside any one app the override
# persists across every subsequently-built app via this field
# (the template fetches /api/settings on mount and PUTs back here on
# toggle, so the preference is shared even though each app runs from
# its own vite port / localStorage origin).
# null = follow system / no override; 'light' or 'dark' = sticky.
app_template_theme_override: Optional[Literal["light", "dark"]] = None
new_agent_shortcut: str = "Meta+l"
anthropic_api_key: Optional[str] = None
browser_homepage: str = "https://www.google.com"
+32 -1
View File
@@ -9,7 +9,7 @@ from contextlib import asynccontextmanager
from fastapi import HTTPException, Query, UploadFile, File
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from typing import Optional
from typing import Literal, Optional
from backend.config.Apps import SubApp
from backend.apps.settings.models import AppSettings, DEFAULT_SYSTEM_PROMPT
@@ -299,6 +299,37 @@ async def update_settings(body: AppSettings):
return {"ok": True, "settings": body.model_dump()}
class AppThemeOverridePayload(BaseModel):
mode: Optional[Literal["light", "dark"]] = None
@settings.router.get("/app-theme-override")
async def get_app_theme_override():
"""Cross-app theme preference for App Builder workspaces.
Returns the current override (or `null` for follow-system). Apps
served from the template fetch this on mount so a toggle inside
any one app sticks across every future app the user builds. Each
app workspace runs on its own vite port (separate localStorage
origin), so the backend is the only place this can live."""
return {"mode": load_settings().app_template_theme_override}
@settings.router.put("/app-theme-override")
async def put_app_theme_override(body: AppThemeOverridePayload):
"""MERGE the theme override into AppSettings. The general PUT
/api/settings endpoint replaces the whole AppSettings object —
sending a partial body there would default every secret-bearing
field (api keys, subscription tokens), which logs the user out
and pops the SignInGate. This dedicated endpoint mutates only
`app_template_theme_override` and leaves every other field
untouched."""
current = load_settings()
current.app_template_theme_override = body.mode
await save_settings_async(current)
return {"ok": True, "mode": current.app_template_theme_override}
@settings.router.get("/default-system-prompt")
async def get_default_system_prompt():
return {"default_system_prompt": DEFAULT_SYSTEM_PROMPT}
@@ -96,12 +96,30 @@ class OnboardingDirector {
// and abort if it changes; lets the user explore freely without
// the AC stranding itself on the wrong page.
const startHash = window.location.hash;
const onLost = () => {
// Console-visible breadcrumb for which abort listener fired. The
// existing `report()` calls only go to analytics; we couldn't tell
// whether step 8's recurring `AbortError: aborted` was from a
// lost-target (chat-input element disconnected by an in-flight
// remount) or from a route change (`hashchange` firing as a side
// effect of e.g. ViewEditor calling history.replaceState mid-flow).
// Logging on each abort path resolves that ambiguity without
// needing to open the Network/Analytics panel.
const onLost = (e: Event) => {
const detail = (e as CustomEvent)?.detail;
// eslint-disable-next-line no-console
console.warn(
`[onboarding] step ${stepId} aborted: lost-target`,
detail,
);
report('step_aborted_lost_target', { step_id: stepId });
controller.abort();
};
const onRouteChange = () => {
if (window.location.hash !== startHash) {
// eslint-disable-next-line no-console
console.warn(
`[onboarding] step ${stepId} aborted: hash changed ${startHash} -> ${window.location.hash}`,
);
report('step_aborted_route_change', {
step_id: stepId,
from: startHash,
@@ -809,6 +809,19 @@ async function runOp(op: ACOp, ctx: RunContext): Promise<void> {
);
}
ac.hidePopup();
// CRITICAL: stop tracking the previous move_to target now that
// the user has engaged with it. Many `wait_user click_target`
// targets are ephemeral — the App Builder's `+ New app` button
// disappears the instant the user clicks it (Views.tsx swaps
// ViewEditor in), and if the tracker keeps watching that now-
// disconnected element, the lost-target watchdog fires after
// 2.5 s and aborts the entire step (step 8 was aborting before
// it ever reached `type_into` for this exact reason — the
// `[onboarding] step make_app aborted: lost-target` console
// line pointed at `apps-new-button`, not at chat-input). The
// tracker for the NEXT target (chat-input, send button, etc.)
// starts in the next move_to / type_into op.
ac.stopTracking();
// The user just did the thing — they don't need a dwell floor on
// top of having engaged with the popup. Clearing popupShownAt
// makes the next op's clearsTransients block a no-op for dwell,
+11 -1
View File
@@ -1282,7 +1282,17 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
{contextPaths.length > 0 && (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, px: 1.5, pt: images.length > 0 ? 0.25 : 1, pb: 0 }}>
{contextPaths.map((cp, idx) => {
const label = cp.path.split('/').filter(Boolean).slice(-2).join('/');
// Friendlier label for the App Builder's auto-attached
// workspace directory. Without this special-case, the chip
// shows `outputs_workspace/ws-mp3pasq6` — a path the user
// never typed and has no idea what it means. The full path
// still lives in the tooltip for any agent/dev who needs
// it. Pattern is stable: every App Builder workspace path
// ends in `outputs_workspace/ws-<random>`.
const isAppWorkspace = /\/outputs_workspace\/ws-[^/]+\/?$/.test(cp.path);
const label = isAppWorkspace
? 'App files'
: cp.path.split('/').filter(Boolean).slice(-2).join('/');
return (
<Tooltip
key={`${cp.path}-${idx}`}
+283 -81
View File
@@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import CircularProgress from '@mui/material/CircularProgress';
import LinearProgress from '@mui/material/LinearProgress';
import Button from '@mui/material/Button';
import IconButton from '@mui/material/IconButton';
import TextField from '@mui/material/TextField';
@@ -44,6 +45,116 @@ import { onboardingBus } from '@/app/components/Onboarding/eventBus';
const WORKSPACE_API = `${API_BASE}/outputs/workspace`;
// ---- App-Builder cold-start placeholder -----------------------------------
//
// On a fresh app the workspace needs npm/vite + (optionally) uvicorn to
// finish booting before the preview iframe has anything to render. That's
// ~60-90s the first time. The old placeholder was a static spinner + a
// jargon-heavy "Cold start can take 60-90 seconds. Check the Terminal tab
// to follow npm install + Vite startup output..." — non-devs read that as
// "something is broken." Two psychology fixes here:
//
// 1. Indeterminate spinners feel STUCK once the user has been staring
// ~20 s. A LinearProgress that ALWAYS moves (even fake, asymptotic
// toward 95%) reads as "still working" forever.
// 2. Rotating quirky status copy ("Brewing your app...", "Wiring
// things up...") gives the eye new info to process every ~6 s, so
// the wait doesn't feel like a single long pause.
//
// Implementation note: progress curve is `95 * (1 - exp(-elapsed / K))`
// with K = 22_000 ms — reaches ~63 % at 20 s, ~85 % at 40 s, ~92 % at
// 60 s, then plateaus. Once the workspace is actually ready, the
// placeholder unmounts (the parent's `showInstallPlaceholder` flips
// false) and ViewPreview takes over with a hard 100 %-feel snap.
const COOK_MESSAGES = [
'Brewing your app',
'Gathering ingredients',
'Wiring things up',
'Tightening the bolts',
'Adding finishing touches',
'Almost there',
];
const InstallPlaceholder: React.FC = () => {
const c = useClaudeTokens();
const [progress, setProgress] = useState<number>(0);
const [messageIdx, setMessageIdx] = useState<number>(0);
useEffect(() => {
const startedAt = Date.now();
const K = 22_000;
const id = window.setInterval(() => {
const elapsed = Date.now() - startedAt;
// 95 * (1 - e^(-t/K)) — fast at first, asymptotes to 95 %.
setProgress(95 * (1 - Math.exp(-elapsed / K)));
}, 200);
return () => window.clearInterval(id);
}, []);
useEffect(() => {
const id = window.setInterval(() => {
setMessageIdx((i) => (i + 1) % COOK_MESSAGES.length);
}, 6000);
return () => window.clearInterval(id);
}, []);
return (
<Box
sx={{
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 2,
bgcolor: c.bg.surface,
p: 4,
textAlign: 'center',
}}
>
<Typography
sx={{
color: c.text.primary,
fontSize: '1.05rem',
fontWeight: 600,
minHeight: '1.5em',
transition: 'opacity 300ms ease',
}}
key={messageIdx}
>
{COOK_MESSAGES[messageIdx]}
</Typography>
<Box sx={{ width: '60%', maxWidth: 360 }}>
<LinearProgress
variant="determinate"
value={progress}
sx={{
height: 6,
borderRadius: 3,
bgcolor: c.bg.elevated,
'& .MuiLinearProgress-bar': {
bgcolor: c.accent.primary,
transition: 'transform 400ms cubic-bezier(0.25, 0.1, 0.25, 1)',
},
}}
/>
</Box>
<Typography
sx={{
color: c.text.ghost,
fontSize: '0.82rem',
maxWidth: 400,
lineHeight: 1.55,
mt: 0.5,
}}
>
First app takes about a minute. After this, new ones spin up in a few seconds.
</Typography>
</Box>
);
};
// File-tree noise defaults. VSCode's equivalent `files.exclude` hides
// the same set (plus a few more) — we apply by basename anywhere in
// the path so e.g. `frontend/node_modules` and `frontend/dist` are
@@ -289,6 +400,15 @@ const ViewEditor: React.FC<Props> = ({ output }) => {
const TERMINAL_BUFFER_CAP = 5000; // trim FIFO past this so we don't grow unbounded
const previewRef = useRef<ViewPreviewHandle>(null);
// `iframePainted` is true once the embedded app's first navigation
// has fired its `load` event PLUS a 300 ms grace for React/Vue/etc.
// to commit its first paint. Used to keep the cold-start placeholder
// overlaid on top of the iframe until the user-visible content is
// actually on screen — otherwise vite reporting "ready" → placeholder
// unmount → iframe-still-loading-its-bundle reads as a grey flash.
// Resets whenever the serve URL changes (new app, vite restart) so
// each load has its own placeholder lifecycle.
const [iframePainted, setIframePainted] = useState(false);
const SIDEBAR_MIN = 280;
const SIDEBAR_MAX = 800;
@@ -838,32 +958,41 @@ const ViewEditor: React.FC<Props> = ({ output }) => {
// 404s — new-mode workspaces have no `index.html` at root, only
// `frontend/index.html` reachable via Vite).
const [isNewModeRuntime, setIsNewModeRuntime] = useState(false);
// Has runtime/start been fired for this workspace yet? Used by the
// tab-gated lifecycle below so we only POST start the FIRST time the
// user lands on (or switches to) Preview/Terminal. Switching between
// tabs after that is a no-op — the runtime is already up and the
// WS is already streaming. Reset when workspaceId changes so a new
// workspace gets its own one-shot.
const runtimeStartedRef = useRef(false);
// Latched flag: true once the user has visited a tab that needs the
// runtime (Preview / Terminal) for the current workspace. Only goes
// true → reset to false ONLY when the workspace changes. Tab flips
// back to Code DON'T reset it, so the lifecycle effect that depends
// on it doesn't tear down on Preview → Code → Preview. This used to
// be a ref (`runtimeStartedRef`) but refs don't trigger re-renders,
// and the lifecycle effect couldn't react to the flip without
// running activeTab through its dep array — which is exactly what
// caused the cleanup-on-tab-switch bug.
const [runtimeShouldRun, setRuntimeShouldRun] = useState(false);
useEffect(() => {
runtimeStartedRef.current = false;
setRuntimeShouldRun(false);
}, [workspaceId]);
// Split into two effects so a tab switch never tears down the
// running workspace. The original single useEffect included
// `activeTab` in its dep array — the early `if (runtimeStartedRef…
// return` skipped re-starting, but the CLEANUP from the prior run
// still executed, POSTing /runtime/stop and clearing both
// frontendUrl and isNewModeRuntime to null. With those reset, the
// showInstallPlaceholder gate (`isNewModeRuntime && !frontendUrl`)
// collapsed to false, workspaceServeUrl fell back to the legacy
// /api/outputs/workspace/<ws>/serve/index.html path which 404s for
// new-mode workspaces → the iframe rendered the raw
// `{"detail":"File not found"}` JSON. The fix is to drive Effect A
// (lifecycle) off a STATE flag that ONLY ever flips true → never
// back to false on tab change, and to let Effect B (one-shot
// trigger) watch activeTab. State flips are visible to React's
// dep checker; ref mutations aren't, so a state flag is the right
// primitive here.
useEffect(() => {
if (!workspaceId) return;
// Defer the workspace runtime spawn until the user actually wants
// to see/hear from it. Code tab is pure editor — no need to pay
// the ~1-2s vite + uvicorn cold-start until they click Preview or
// Terminal. After the first entry, the runtime stays up (LRU pools
// it on unmount), so subsequent tab flips are free.
const wantsRuntime = activeTab === TAB_PREVIEW || activeTab === TAB_TERMINAL;
if (!wantsRuntime && !runtimeStartedRef.current) return;
if (runtimeStartedRef.current) return;
runtimeStartedRef.current = true;
if (!workspaceId || !runtimeShouldRun) return;
let cancelled = false;
let ws: WebSocket | null = null;
setFrontendUrl(null); // reset when workspace changes
setFrontendUrl(null);
setIsNewModeRuntime(false);
const auth = getAuthToken();
@@ -887,13 +1016,6 @@ const ViewEditor: React.FC<Props> = ({ output }) => {
try {
const msg = JSON.parse(ev.data);
if (msg.event === 'runtime:status') {
// New-mode runtimes report a frontend_url here as soon as
// Vite has actually bound (the runtime poll-gates it);
// old-mode workspaces report null and the preview pane
// stays on the legacy /serve/ path. We also track
// is_new_mode separately so the placeholder pane shows
// "Installing dependencies…" instead of the 404'd /serve
// path while Vite is mid-npm-install.
const fu = msg.data?.frontend_url ?? null;
setFrontendUrl(fu || null);
setIsNewModeRuntime(!!msg.data?.is_new_mode);
@@ -922,7 +1044,21 @@ const ViewEditor: React.FC<Props> = ({ output }) => {
headers,
}).catch(() => {});
};
}, [workspaceId, appendTerminalLine, activeTab]);
}, [workspaceId, runtimeShouldRun, appendTerminalLine]);
// Effect B — one-shot trigger. The first time the user lands on a
// tab that actually needs the runtime (Preview or Terminal), flip
// runtimeShouldRun true. Effect A picks that up and fires
// /runtime/start. After the flip, switching back to Code does NOT
// flip it false — the runtime stays warm because the LRU pool
// keeps it alive and tab flips should be free.
useEffect(() => {
if (!workspaceId) return;
if (runtimeShouldRun) return;
const wantsRuntime = activeTab === TAB_PREVIEW || activeTab === TAB_TERMINAL;
if (!wantsRuntime) return;
setRuntimeShouldRun(true);
}, [workspaceId, activeTab, runtimeShouldRun, TAB_PREVIEW, TAB_TERMINAL]);
// Preview URL: prefer the new-mode Vite dev server when the runtime
// reports one; otherwise fall back to the legacy serve endpoint.
@@ -936,6 +1072,25 @@ const ViewEditor: React.FC<Props> = ({ output }) => {
? undefined
: (frontendUrl ?? (workspaceId ? `${SERVE_BASE}/workspace/${workspaceId}/serve/index.html` : undefined));
// Reset the paint-tracking flag when the iframe's source URL changes —
// each new URL is a fresh load and the placeholder needs to stay up
// until THAT URL's content paints, not whatever paint happened last
// time.
useEffect(() => {
setIframePainted(false);
}, [workspaceServeUrl]);
// Called by ViewPreview when its iframe (or webview) fires the `load`
// event for a real serveUrl. We delay flipping the painted flag by
// 300 ms — the `load` event fires when the HTML doc has loaded but
// SPA bundles (React/Vue/etc) need a beat to mount and paint, so an
// immediate flip would re-introduce the grey flash we're trying to
// kill.
const onIframeContentLoad = useCallback(() => {
const t = window.setTimeout(() => setIframePainted(true), 300);
return () => window.clearTimeout(t);
}, []);
// VSCode-style default `files.exclude`: hide build/install noise from
// the file tree by default. With the symlinked node_modules + vite's
// per-workspace .vite-cache, an unfiltered tree renders hundreds of
@@ -1120,14 +1275,20 @@ const ViewEditor: React.FC<Props> = ({ output }) => {
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1.5,
// `baseline` aligns the text baselines of the two inputs so
// "App name" (0.9 rem, weight 600) and "Description"
// (0.78 rem) sit on the same visual line. Previously
// `alignItems: center` centered each input's containing
// box, which differ in height because one is bigger and
// one had `size="small"` — that's why the smaller
// Description field floated higher than the App name.
alignItems: 'baseline',
gap: 2,
px: 1.5,
py: 0.75,
borderBottom: `1px solid ${c.border.subtle}`,
py: 1,
bgcolor: c.bg.secondary,
flexShrink: 0,
minHeight: 44,
minHeight: 48,
}}
>
<TextField
@@ -1138,7 +1299,12 @@ const ViewEditor: React.FC<Props> = ({ output }) => {
sx={{
flex: 1,
maxWidth: 220,
'& .MuiInput-input': { fontSize: '0.9rem', fontWeight: 600, color: c.text.primary },
'& .MuiInput-input': {
fontSize: '0.9rem',
fontWeight: 600,
color: c.text.primary,
py: 0.25,
},
'& .MuiInput-underline:before': { borderColor: 'transparent' },
'& .MuiInput-underline:hover:before': { borderColor: c.border.medium },
}}
@@ -1149,11 +1315,19 @@ const ViewEditor: React.FC<Props> = ({ output }) => {
onChange={(e) => setDescription(e.target.value)}
placeholder="Description"
variant="standard"
size="small"
sx={{
flex: 2,
'& .MuiInput-input': { fontSize: '0.78rem', color: c.text.muted },
'& .MuiInput-input': {
fontSize: '0.82rem',
color: c.text.muted,
// Match the App-name input's vertical padding so the
// two inputs occupy the same internal height — without
// this the baselines drift by a couple pixels even
// with `alignItems: baseline` on the parent.
py: 0.25,
},
'& .MuiInput-underline:before': { borderColor: 'transparent' },
'& .MuiInput-underline:hover:before': { borderColor: c.border.medium },
}}
/>
@@ -1164,32 +1338,57 @@ const ViewEditor: React.FC<Props> = ({ output }) => {
sx={{
display: 'flex',
alignItems: 'center',
borderBottom: `1px solid ${c.border.subtle}`,
// Drop the hard borderBottom — let bg-color step between
// this tab strip and the content below carry the
// separation. Claude Design's pane edges are nearly
// invisible, which is what makes them read as airy.
bgcolor: c.bg.secondary,
flexShrink: 0,
px: 1.25,
py: 1,
}}
>
<Tabs
value={activeTab}
onChange={(_, v) => setActiveTab(v)}
// Hide the underline indicator entirely — we're showing
// active state via background-fill pills instead, matching
// Claude Design's "Recent / Your designs" toggle pattern.
TabIndicatorProps={{ sx: { display: 'none' } }}
sx={{
flex: 1,
minHeight: 36,
minHeight: 32,
'& .MuiTabs-flexContainer': {
gap: 0.5,
},
'& .MuiTab-root': {
minHeight: 36,
fontSize: '0.78rem',
minHeight: 32,
minWidth: 'auto',
fontSize: '0.8rem',
textTransform: 'none',
fontWeight: 500,
color: c.text.tertiary,
px: 1.75,
py: 0,
},
'& .MuiTabs-indicator': {
bgcolor: c.accent.primary,
borderRadius: 999,
transition: c.transition,
'&:hover': {
color: c.text.secondary,
bgcolor: `${c.text.primary}06`,
},
'&.Mui-selected': {
color: c.text.primary,
// Background-fill pill instead of underline indicator
// — same active-state language as a sidebar nav item.
bgcolor: c.bg.elevated,
fontWeight: 600,
},
},
}}
>
<Tab label="Preview" value={TAB_PREVIEW} />
<Tab label="Code" value={TAB_CODE} />
<Tab label="Terminal" value={TAB_TERMINAL} />
<Tab disableRipple label="Preview" value={TAB_PREVIEW} />
<Tab disableRipple label="Code" value={TAB_CODE} />
<Tab disableRipple label="Terminal" value={TAB_TERMINAL} />
</Tabs>
{activeTab === TAB_PREVIEW && (
<Tooltip title="Reload preview · right-click for Hard Reload">
@@ -1230,41 +1429,44 @@ const ViewEditor: React.FC<Props> = ({ output }) => {
{/* Tab content */}
<Box sx={{ flex: 1, overflow: 'hidden' }}>
{activeTab === TAB_PREVIEW && (
showInstallPlaceholder ? (
<Box
sx={{
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 1.5,
bgcolor: c.bg.surface,
p: 3,
textAlign: 'center',
}}
>
<CircularProgress size={28} sx={{ color: c.accent.primary }} />
<Typography sx={{ color: c.text.primary, fontSize: '0.95rem', fontWeight: 600 }}>
Installing dependencies
</Typography>
<Typography sx={{ color: c.text.ghost, fontSize: '0.82rem', maxWidth: 420, lineHeight: 1.5 }}>
Cold start can take 6090 seconds. Check the <b>Terminal</b> tab to follow{' '}
npm install + Vite startup output. The preview will appear here automatically{' '}
once Vite is ready.
</Typography>
</Box>
) : (
<ViewPreview
ref={previewRef}
serveUrl={workspaceServeUrl}
frontendCode={!workspaceServeUrl ? (files['index.html'] ?? '') : undefined}
inputData={testInput}
backendResult={null}
onConsoleMessage={handleWebviewConsole}
/>
)
<Box sx={{ position: 'relative', width: '100%', height: '100%' }}>
{/* Iframe always renders the moment we HAVE a URL — even
while the install placeholder is still on top — so the
embedded app's first paint completes BEFORE we fade
the placeholder out. Otherwise the user sees a
~1-2 s window of blank/grey "iframe loaded but app
hasn't painted yet" once `showInstallPlaceholder`
flips false. */}
{(workspaceServeUrl || !showInstallPlaceholder) && (
<ViewPreview
ref={previewRef}
serveUrl={workspaceServeUrl}
frontendCode={!workspaceServeUrl ? (files['index.html'] ?? '') : undefined}
inputData={testInput}
backendResult={null}
onConsoleMessage={handleWebviewConsole}
onContentLoad={onIframeContentLoad}
/>
)}
{/* Overlay placeholder until the iframe has painted +
a 300 ms grace for the SPA's first React commit.
Fades out instead of unmount-snap so the transition
is smooth and the user never sees a flash. */}
{(showInstallPlaceholder || !iframePainted) && (
<Box
sx={{
position: 'absolute',
inset: 0,
zIndex: 5,
opacity: showInstallPlaceholder || !iframePainted ? 1 : 0,
transition: 'opacity 400ms ease',
pointerEvents: iframePainted ? 'none' : 'auto',
}}
>
<InstallPlaceholder />
</Box>
)}
</Box>
)}
{activeTab === TAB_CODE && (
<Box sx={{ display: 'flex', height: '100%' }}>
+30 -4
View File
@@ -5,6 +5,7 @@ import { Skeleton } from '@/app/components/Loading';
import { useElementSelection } from '@/app/components/ElementSelectionContext';
import { useIframeElementSelector } from './useIframeElementSelector';
import { getAuthToken, ensureAuthToken } from '@/shared/config';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
// We render apps in a <webview> when running inside the Electron shell so
// they escape iframe restrictions (popups, mic/camera, WebAuthn,
@@ -29,6 +30,13 @@ interface Props {
* running app (captured by webview-preload.js → ipc-message). Only
* fires in the webview path — iframes have no comparable channel. */
onConsoleMessage?: (level: string, text: string) => void;
/** Fires once the iframe/webview has finished its first navigation +
* load event for a given serveUrl. Lets parents (ViewEditor) keep
* the cold-start placeholder visible until the embedded app has
* actually painted, instead of unmounting the placeholder the moment
* vite reports "ready" (which leaves a 1-2 s window where the
* iframe has a URL but no content → visible grey flash). */
onContentLoad?: () => void;
}
function buildSrcdoc(
@@ -65,10 +73,20 @@ const ViewPreview = forwardRef<ViewPreviewHandle, Props>(({
backendResult = null,
style,
onConsoleMessage,
onContentLoad,
}, ref) => {
const iframeRef = useRef<HTMLIFrameElement>(null);
const webviewRef = useRef<any>(null);
const ctx = useElementSelection();
// Match the iframe/webview's BG to the OpenSwarm host's theme during
// load. Previously hardcoded '#fff', which on a dark OpenSwarm host
// produced a jarring white flash for the 60-90 s between vite spawn
// and first paint, then ANOTHER flash to the same white when the
// app reattached. Using the host's page color means the loading
// state visually blends with the chrome around it — no flashes
// until the app's own theme paints over it.
const _hostTokens = useClaudeTokens();
const _hostBg = _hostTokens.bg.page;
const [reloadKey, setReloadKey] = useState(0);
// Track auth token in state so the iframe URL is rebuilt the moment the
// token IPC roundtrip resolves. Without this, the first render runs while
@@ -136,7 +154,15 @@ const ViewPreview = forwardRef<ViewPreviewHandle, Props>(({
// load fires for both the about:blank pause-step AND the restored URL —
// only the latter should clear the overlay.
if (!windowHidden) setRestoring(false);
}, [windowHidden]);
// Notify parent that an actual URL just finished loading. Skip the
// about:blank pauses (those happen while the OpenSwarm window is
// hidden) — those aren't user-visible content paints. The parent
// (ViewEditor) uses this to know when its install-placeholder can
// safely fade away.
if (!windowHidden && onContentLoad) {
onContentLoad();
}
}, [windowHidden, onContentLoad]);
const srcdoc = useMemo(() => {
if (serveUrl || !frontendCode) return undefined;
@@ -291,7 +317,7 @@ const ViewPreview = forwardRef<ViewPreviewHandle, Props>(({
width: '100%',
height: '100%',
border: 'none',
background: '#fff',
background: _hostBg,
...style,
}}
/>
@@ -314,7 +340,7 @@ const ViewPreview = forwardRef<ViewPreviewHandle, Props>(({
width: '100%',
height: '100%',
border: 'none',
background: '#fff',
background: _hostBg,
...style,
}}
title="App Preview"
@@ -330,7 +356,7 @@ const ViewPreview = forwardRef<ViewPreviewHandle, Props>(({
alignItems: 'center',
justifyContent: 'center',
gap: 1.5,
bgcolor: '#fff',
bgcolor: _hostBg,
zIndex: 2,
pointerEvents: 'none',
}}