mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-09 19:27:45 +02:00
[eric] App Builder polish + onboarding fix
This commit is contained in:
@@ -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;
|
||||
+52
-26
@@ -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: {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`}
|
||||
|
||||
@@ -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 60–90 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%' }}>
|
||||
|
||||
@@ -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',
|
||||
}}
|
||||
|
||||
Reference in New Issue
Block a user