diff --git a/backend/apps/outputs/app_builder_skill.md b/backend/apps/outputs/app_builder_skill.md index bd26fc7c..6ad152de 100644 --- a/backend/apps/outputs/app_builder_skill.md +++ b/backend/apps/outputs/app_builder_skill.md @@ -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: diff --git a/backend/apps/outputs/webapp_template/frontend/src/app/components/ErrorBoundary.tsx b/backend/apps/outputs/webapp_template/frontend/src/app/components/ErrorBoundary.tsx new file mode 100644 index 00000000..6fdacd98 --- /dev/null +++ b/backend/apps/outputs/webapp_template/frontend/src/app/components/ErrorBoundary.tsx @@ -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 { + state: ErrorBoundaryState = { error: null, errorInfo: null }; + + static getDerivedStateFromError(error: Error): Partial { + 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 ( +
+
+

This app hit a snag.

+

+ 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. +

+ + {error.message || String(error)} + + {errorInfo?.componentStack && ( + + {errorInfo.componentStack.trim()} + + )} + +
+
+ ); + } +} + +export default ErrorBoundary; diff --git a/backend/apps/outputs/webapp_template/frontend/src/app/components/Layout/Sidebar.tsx b/backend/apps/outputs/webapp_template/frontend/src/app/components/Layout/Sidebar.tsx index 1c16a525..5950a9c6 100644 --- a/backend/apps/outputs/webapp_template/frontend/src/app/components/Layout/Sidebar.tsx +++ b/backend/apps/outputs/webapp_template/frontend/src/app/components/Layout/Sidebar.tsx @@ -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 = ({ 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 = ({ 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', }} > {!collapsed && ( @@ -85,7 +101,7 @@ const Sidebar: React.FC = ({ collapsed, onToggle }) => { )} - + {NAV_ITEMS.map(({ path, label, icon: Icon }) => { const isActive = path === '/' ? location.pathname === '/' : location.pathname.startsWith(path); @@ -96,24 +112,32 @@ const Sidebar: React.FC = ({ 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, }} > - + {!collapsed && ( = ({ 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 = ({ 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 = ({ 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 = ({ collapsed, onToggle }) => { {!collapsed && ( diff --git a/backend/apps/outputs/webapp_template/frontend/src/assets/logo.png b/backend/apps/outputs/webapp_template/frontend/src/assets/logo.png new file mode 100644 index 00000000..8b9f8611 Binary files /dev/null and b/backend/apps/outputs/webapp_template/frontend/src/assets/logo.png differ diff --git a/backend/apps/outputs/webapp_template/frontend/src/index.tsx b/backend/apps/outputs/webapp_template/frontend/src/index.tsx index b970a9af..6c7c991f 100644 --- a/backend/apps/outputs/webapp_template/frontend/src/index.tsx +++ b/backend/apps/outputs/webapp_template/frontend/src/index.tsx @@ -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(
); + // 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( + +
+ , + ); console.log('[App] React root mounted'); } diff --git a/backend/apps/outputs/webapp_template/frontend/src/pages/index.tsx b/backend/apps/outputs/webapp_template/frontend/src/pages/index.tsx index 4314518e..06a9204b 100644 --- a/backend/apps/outputs/webapp_template/frontend/src/pages/index.tsx +++ b/backend/apps/outputs/webapp_template/frontend/src/pages/index.tsx @@ -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 = () => { }} > + {/* 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. */} OpenSwarm - Web app template — ready to build. + Web app template, ready to build. diff --git a/backend/apps/outputs/webapp_template/frontend/src/shared/styles/ThemeContext.tsx b/backend/apps/outputs/webapp_template/frontend/src/shared/styles/ThemeContext.tsx index 88256a43..baae42fe 100644 --- a/backend/apps/outputs/webapp_template/frontend/src/shared/styles/ThemeContext.tsx +++ b/backend/apps/outputs/webapp_template/frontend/src/shared/styles/ThemeContext.tsx @@ -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=` 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({ - mode: 'light', + mode: _bootMode, toggleMode: () => {}, }); -const TokensContext = createContext(buildTokens('light')); +const TokensContext = createContext(buildTokens(_bootMode)); export function useThemeMode() { return useContext(ThemeModeContext); @@ -133,12 +205,91 @@ interface ClaudeThemeProviderProps { } const ClaudeThemeProvider: React.FC = ({ children }) => { - const [mode, setMode] = useState('light'); + const [mode, setMode] = useState(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(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 = ({ children }) = createTheme({ palette: { mode }, typography: { - fontFamily: FONT_SERIF, + fontFamily: FONT_SANS, button: { textTransform: 'none' as const }, }, components: { diff --git a/backend/apps/settings/models.py b/backend/apps/settings/models.py index 1d703cb6..8c5c6d9d 100644 --- a/backend/apps/settings/models.py +++ b/backend/apps/settings/models.py @@ -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" diff --git a/backend/apps/settings/settings.py b/backend/apps/settings/settings.py index 81d0d357..8188c1b6 100644 --- a/backend/apps/settings/settings.py +++ b/backend/apps/settings/settings.py @@ -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} diff --git a/frontend/src/app/components/Onboarding/OnboardingDirector.ts b/frontend/src/app/components/Onboarding/OnboardingDirector.ts index 880c3c3d..24704eb5 100644 --- a/frontend/src/app/components/Onboarding/OnboardingDirector.ts +++ b/frontend/src/app/components/Onboarding/OnboardingDirector.ts @@ -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, diff --git a/frontend/src/app/components/Onboarding/ac/acRuntime.ts b/frontend/src/app/components/Onboarding/ac/acRuntime.ts index 1726e6a1..4f32c81b 100644 --- a/frontend/src/app/components/Onboarding/ac/acRuntime.ts +++ b/frontend/src/app/components/Onboarding/ac/acRuntime.ts @@ -809,6 +809,19 @@ async function runOp(op: ACOp, ctx: RunContext): Promise { ); } 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, diff --git a/frontend/src/app/pages/AgentChat/ChatInput.tsx b/frontend/src/app/pages/AgentChat/ChatInput.tsx index f917cfbf..9f5d65d5 100644 --- a/frontend/src/app/pages/AgentChat/ChatInput.tsx +++ b/frontend/src/app/pages/AgentChat/ChatInput.tsx @@ -1282,7 +1282,17 @@ const ChatInput = forwardRef(({ onSend, disabled, mode, {contextPaths.length > 0 && ( 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-`. + const isAppWorkspace = /\/outputs_workspace\/ws-[^/]+\/?$/.test(cp.path); + const label = isAppWorkspace + ? 'App files' + : cp.path.split('/').filter(Boolean).slice(-2).join('/'); return ( { + const c = useClaudeTokens(); + const [progress, setProgress] = useState(0); + const [messageIdx, setMessageIdx] = useState(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 ( + + + {COOK_MESSAGES[messageIdx]}… + + + + + + First app takes about a minute. After this, new ones spin up in a few seconds. + + + ); +}; + // 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 = ({ output }) => { const TERMINAL_BUFFER_CAP = 5000; // trim FIFO past this so we don't grow unbounded const previewRef = useRef(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 = ({ 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//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 = ({ 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 = ({ 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 = ({ 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 = ({ output }) => { = ({ 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 = ({ 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 = ({ 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, }} > 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, + }, }, }} > - - - + + + {activeTab === TAB_PREVIEW && ( @@ -1230,41 +1429,44 @@ const ViewEditor: React.FC = ({ output }) => { {/* Tab content */} {activeTab === TAB_PREVIEW && ( - showInstallPlaceholder ? ( - - - - Installing dependencies… - - - Cold start can take 60–90 seconds. Check the Terminal tab to follow{' '} - npm install + Vite startup output. The preview will appear here automatically{' '} - once Vite is ready. - - - ) : ( - - ) + + {/* 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) && ( + + )} + {/* 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) && ( + + + + )} + )} {activeTab === TAB_CODE && ( diff --git a/frontend/src/app/pages/Views/ViewPreview.tsx b/frontend/src/app/pages/Views/ViewPreview.tsx index 078496ea..fa6cf470 100644 --- a/frontend/src/app/pages/Views/ViewPreview.tsx +++ b/frontend/src/app/pages/Views/ViewPreview.tsx @@ -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 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(({ backendResult = null, style, onConsoleMessage, + onContentLoad, }, ref) => { const iframeRef = useRef(null); const webviewRef = useRef(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(({ // 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(({ width: '100%', height: '100%', border: 'none', - background: '#fff', + background: _hostBg, ...style, }} /> @@ -314,7 +340,7 @@ const ViewPreview = forwardRef(({ width: '100%', height: '100%', border: 'none', - background: '#fff', + background: _hostBg, ...style, }} title="App Preview" @@ -330,7 +356,7 @@ const ViewPreview = forwardRef(({ alignItems: 'center', justifyContent: 'center', gap: 1.5, - bgcolor: '#fff', + bgcolor: _hostBg, zIndex: 2, pointerEvents: 'none', }}