[eric] App Builder saves to sidebar on seed + onboarding fallbacks (auto-skip rules, fallback prompts, retry popup)

This commit is contained in:
ciregenz
2026-05-12 13:11:56 -07:00
parent a84aec257b
commit c0c52e33d6
20 changed files with 556 additions and 190 deletions
+37
View File
@@ -121,6 +121,43 @@ export default function Card() {
- **Use the `sx` prop** for styles, not separate CSS files.
- **Don't add Tailwind**, Bootstrap, or any other CSS framework.
### MUI imports — ALWAYS use path imports, NEVER barrel imports
This is non-negotiable. Vite pre-bundles every entry in a barrel import,
which means a single `import { Button } from '@mui/material'` forces Vite
to optimize 200+ MUI sub-modules — adding ~10–15 seconds to every cold
boot of the workspace's preview. MUI's own performance guide
(<https://mui.com/material-ui/guides/minimizing-bundle-size/>) recommends
path imports for exactly this reason.
```tsx
// ✅ DO — path imports, one per component
import Button from '@mui/material/Button';
import Box from '@mui/material/Box';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
// ❌ DON'T — barrel imports drag in all of @mui/material
import { Button, Box, Stack, Typography } from '@mui/material';
```
Same rule for icons — even more important there because
`@mui/icons-material` re-exports thousands of SVG components:
```tsx
// ✅ DO
import AddIcon from '@mui/icons-material/Add';
import DeleteIcon from '@mui/icons-material/Delete';
// ❌ DON'T
import { Add, Delete } from '@mui/icons-material';
```
**Icon discipline:** keep icon imports to the minimum the UI actually
uses. If a page only needs 4 icons, import 4 — don't pre-import 20 for
"maybe later." Each icon import is another module Vite has to pre-bundle
on first boot.
Check `frontend/DESIGN.md` for the complete design system spec.
---
+119 -14
View File
@@ -5,6 +5,7 @@ import logging
import mimetypes
import base64
from datetime import datetime
from typing import Optional
from contextlib import asynccontextmanager
from fastapi import HTTPException, Query
from fastapi.responses import Response
@@ -217,16 +218,73 @@ def load_output(output_id: str) -> Output | None:
return Output(**json.load(f))
# Build/install/cache directories that the polling endpoint must never
# descend into. Without this skip-list the workspace endpoint reads
# `node_modules/` (300 MB of MUI source, when it's a real dir and not a
# symlink), `.venv/` (10k+ Python files from the hardlinked cache),
# `__pycache__/`, `dist/`, `.git/`, etc — every 2 seconds while the
# agent is active. Result: backend CPU pegged on JSON-serializing
# auto-generated chunks the frontend will then throw away. The frontend
# already filters these for display; this skip is the real fix.
_WALK_SKIP_DIRS = frozenset({
"node_modules",
".vite",
".vite-cache",
".vite_cache",
".git",
"dist",
".next",
"__pycache__",
".venv",
"venv",
".pytest_cache",
".mypy_cache",
".ruff_cache",
})
# Cap per-file response size at 256 KB. Hand-written source rarely
# exceeds this; auto-generated bundles routinely run into the MBs and
# they're not what the user/agent is editing. Anything over the cap
# returns a truncated stub the frontend treats as "open the file
# directly to see full contents."
_WALK_MAX_FILE_BYTES = 256 * 1024
def _walk_directory(folder: str) -> dict[str, str]:
"""Walk a directory tree and return {relative_path: content} for all text files."""
"""Walk a directory tree and return {relative_path: content} for all
text files the user is actually authoring. Skips build/install
directories AND truncates oversize files — both critical for the
polling endpoint, which is called every 2 s while the agent is
writing code and would otherwise serialize hundreds of MB per poll."""
files: dict[str, str] = {}
if not os.path.isdir(folder):
return files
for root, _dirs, filenames in os.walk(folder):
for root, dirs, filenames in os.walk(folder):
# Mutate `dirs` in place — that's how os.walk skips a subtree.
# Doing it here means we never even stat the children, so a
# 10k-file `.venv/` costs ~one stat (on the dir itself) instead
# of 10k.
dirs[:] = [d for d in dirs if d not in _WALK_SKIP_DIRS]
for fname in filenames:
full_path = os.path.join(root, fname)
rel_path = os.path.relpath(full_path, folder)
# Normalize to forward-slash keys so the frontend's
# `path.split('/')` and `.startsWith(prefix)` checks work
# the same on Windows (where os.sep is '\\') as on macOS.
# Without this, every workspace file came back as
# `backend\\app.py` on Windows and the file tree silently
# mis-parsed.
rel_path = os.path.relpath(full_path, folder).replace(os.sep, "/")
try:
# Stat first — cheap, lets us skip giant files without
# opening + reading them.
size = os.path.getsize(full_path)
if size > _WALK_MAX_FILE_BYTES:
files[rel_path] = (
f"// [openswarm] file truncated ({size} bytes > "
f"{_WALK_MAX_FILE_BYTES} byte cap). Open directly "
f"to view full contents."
)
continue
with open(full_path) as f:
files[rel_path] = f.read()
except Exception:
@@ -347,19 +405,66 @@ async def seed_workspace(body: WorkspaceSeedRequest):
effective_mode = "flat"
if effective_mode == "webapp_template":
# Defer to the helper — copytree, env scaffolding, install paths.
from backend.apps.outputs.runtime import _find_free_port
frontend_port = _find_free_port()
seed_webapp_template_workspace(folder, frontend_port)
# SKILL.md still goes in workspace root — agent reads it for
# context. Live content (user-editable via Skills page) is
# injected into the system prompt regardless.
with open(os.path.join(folder, "SKILL.md"), "w") as f:
f.write(load_app_builder_skill())
if body.meta:
# Idempotency guard: re-seeding an existing webapp_template
# workspace would clobber the agent's edits (the helper uses
# dirs_exist_ok=True + copytree). If `run.sh` already exists,
# the workspace was seeded on a previous visit — skip the file
# copy and only re-derive the frontend port from .env.
from backend.apps.outputs.runtime import _find_free_port, _read_env_value
already_seeded = os.path.exists(os.path.join(folder, "run.sh"))
if already_seeded:
fp_raw = _read_env_value(os.path.join(folder, ".env"), "FRONTEND_PORT")
try:
frontend_port = int(fp_raw) if fp_raw else _find_free_port()
except (TypeError, ValueError):
frontend_port = _find_free_port()
else:
frontend_port = _find_free_port()
seed_webapp_template_workspace(folder, frontend_port)
# SKILL.md still goes in workspace root — agent reads it for
# context. Live content (user-editable via Skills page) is
# injected into the system prompt regardless.
with open(os.path.join(folder, "SKILL.md"), "w") as f:
f.write(load_app_builder_skill())
meta = body.meta or {}
if body.meta and not already_seeded:
with open(os.path.join(folder, "meta.json"), "w") as f:
json.dump(body.meta, f, indent=2)
return {"path": os.path.abspath(folder), "template_mode": "webapp_template", "frontend_port": frontend_port}
# Create (or look up) the Output record so the app appears in
# the Apps sidebar the moment the user kicks off generation.
# Previously the record only landed when the editor's autosave
# fired, which itself was gated on `files['index.html']` being
# non-empty (a flat-template invariant) — meaning React+Vite
# apps that navigated-away mid-build had no way back. The record
# is a thin pointer (name + workspace_id); the workspace itself
# remains the source of truth for the code.
output_id: Optional[str] = None
try:
existing = [o for o in _load_all() if o.workspace_id == body.workspace_id]
if existing:
output_id = existing[0].id
else:
now = datetime.now().isoformat()
output = Output(
name=str(meta.get("name") or "Untitled App"),
description=str(meta.get("description") or ""),
icon="view_quilt",
files={},
workspace_id=body.workspace_id,
created_at=now,
updated_at=now,
)
_save(output)
output_id = output.id
except Exception:
logger.exception("seed-time Output create failed for %s", body.workspace_id)
return {
"path": os.path.abspath(folder),
"template_mode": "webapp_template",
"frontend_port": frontend_port,
"output_id": output_id,
"already_seeded": already_seeded,
}
# Legacy flat path — unchanged.
if body.files:
+19 -5
View File
@@ -301,14 +301,23 @@ def _ensure_warm_python_venv() -> str | None:
try:
os.makedirs(cache_dir, exist_ok=True)
# Pick the same python the workspace's run.sh would have
# picked, so the venv's binary is compatible.
# picked, so the venv's binary is compatible. Includes
# bare `python` as the last fallback for Windows, where
# there's no `python3` symlink — the installer ships just
# `python.exe`. On macOS/Linux the versioned candidates
# match first so we don't accidentally pick a system
# Python 2.x via the bare name.
py = None
for candidate in ("python3.13", "python3.12", "python3.11", "python3.10", "python3"):
candidates = (
"python3.13", "python3.12", "python3.11", "python3.10",
"python3", "python",
)
for candidate in candidates:
if shutil.which(candidate):
py = candidate
break
if py is None:
logger.warning("webapp-template warm-venv: no python3 on PATH")
logger.warning("webapp-template warm-venv: no python on PATH")
return None
# Wipe any half-populated venv from a previous crashed run.
@@ -327,8 +336,13 @@ def _ensure_warm_python_venv() -> str | None:
# Install the template's dependencies (fastapi[standard],
# typeguard, transitives) — NOT the workspace's own backend,
# which gets editable-installed per-workspace by run.sh after
# the cache copy.
pip = os.path.join(venv_dir, "bin", "pip")
# the cache copy. The venv layout differs by platform:
# POSIX puts executables in `bin/`, Windows in `Scripts/`,
# and the executable name itself gets `.exe`.
if os.name == "nt":
pip = os.path.join(venv_dir, "Scripts", "pip.exe")
else:
pip = os.path.join(venv_dir, "bin", "pip")
deps = ["fastapi[standard]", "typeguard==4.4.2"]
r = subprocess.run(
[pip, "install", "--disable-pip-version-check", *deps],
@@ -75,6 +75,13 @@ else
fi
# --- Start the backend server ---
# No --reload here: this is the user's generated workspace, not an
# OpenSwarm dev environment. The agent rewrites files whole-file
# during builds; uvicorn's WatchFiles supervisor would just tear down
# the running server every keystroke. When the agent explicitly wants
# the backend to pick up new code it can hit OpenSwarm's
# /api/outputs/workspace/{ws}/runtime/restart endpoint, which sends a
# clean SIGTERM and restarts via this same script.
echo "Starting backend server on http://0.0.0.0:${BACKEND_PORT:-8324} ..."
cd "$BACKEND_DIR_ABSPATH/.."
python -m uvicorn backend.main:app --host 0.0.0.0 --port "${BACKEND_PORT:-8324}" --reload
python -m uvicorn backend.main:app --host 0.0.0.0 --port "${BACKEND_PORT:-8324}"
@@ -3,23 +3,52 @@ import react from '@vitejs/plugin-react';
import Pages from 'vite-plugin-pages';
import terminal from 'vite-plugin-terminal';
import path from 'path';
import os from 'os';
import fs from 'fs';
// Shared, hash-keyed vite optimization cache. Every webapp-template
// workspace shares its node_modules/ via a symlink to OpenSwarm's warm
// cache, AND now shares the optimized-deps output via this cache too —
// keyed on the hash of vite.config.ts + package.json so a real config
// or dep bump invalidates automatically. First workspace ever opened
// pays the ~10–15s MUI pre-bundle; every subsequent workspace reuses
// the same `.vite-cache/deps/` and boots in under a second.
//
// Why this is safe (despite the earlier React-duplicate issue):
// 1. The skill prompt now mandates MUI path-imports — so every
// workspace ends up with the SAME, small, deduped optimizeDeps
// set. No more "workspace A pre-bundled @mui/material barrel,
// workspace B pre-bundled @mui/material/Button — collision."
// 2. resolve.dedupe pins react/react-dom/emotion to single instances
// from the symlinked node_modules root.
// 3. Vite's own metadata.json swap is atomic, so concurrent boots
// don't corrupt the cache.
function sharedViteCacheDir(): string {
const here = __dirname;
let digest = 'fallback';
try {
const crypto = require('crypto') as typeof import('crypto');
const hasher = crypto.createHash('sha256');
for (const f of ['vite.config.ts', 'package.json']) {
const p = path.join(here, f);
if (fs.existsSync(p)) hasher.update(fs.readFileSync(p));
}
digest = hasher.digest('hex').slice(0, 12);
} catch {
// Fall through — if hashing fails we still get a stable shared
// cache, just under one "fallback" key.
}
const base = process.env.OPENSWARM_VITE_CACHE_DIR
|| path.join(os.homedir(), '.openswarm', 'cache', 'webapp_template_vite_cache');
return path.join(base, digest);
}
export default defineConfig(({ mode }) => {
const backendPort = process.env.BACKEND_PORT;
const backendEnabled = backendPort && backendPort !== 'NONE';
return {
// Per-workspace vite optimization cache. Workspaces share their
// `node_modules/` directory via a symlink to the OpenSwarm warm
// cache (see view_builder_templates.py::_ensure_warm_cache), which
// by default would also share `node_modules/.vite/` — meaning the
// optimized-deps bundle from workspace A could be picked up by
// workspace B's vite, ending up with two React copies in the same
// iframe (`Invalid hook call` / `Cannot read properties of null
// (reading 'useState')`). Pointing cacheDir outside node_modules
// makes the optimization cache per-workspace while keeping the
// package files themselves shared.
cacheDir: '.vite-cache',
cacheDir: sharedViteCacheDir(),
plugins: [
react(),
Pages({ dirs: 'src/pages', extensions: ['tsx'] }),
+16 -3
View File
@@ -82,6 +82,19 @@ for d in \
fi
done
python3 -m uvicorn backend.main:app --host 0.0.0.0 --port 8324 --reload \
--reload-dir "$BACKEND_DIR_ABSPATH" \
"${UVICORN_EXCLUDE_ARGS[@]}"
# --reload is purely a dev-loop convenience — auto-restart on source
# edits. Useless for end users running the packaged DMG (no source to
# edit) and actively harmful: WatchFiles uses real fs handles, the
# reload supervisor adds a couple hundred MB of resident memory, and
# every reload tears down running agent WebSockets. Only enable it
# when the top-level run.sh has set OPENSWARM_DEV=1 (which the dev
# launcher does). Packaged builds leave it unset → fast, lean,
# single-process uvicorn.
if [[ "${OPENSWARM_DEV:-}" == "1" ]]; then
echo "OPENSWARM_DEV=1 detected — running uvicorn with --reload."
python3 -m uvicorn backend.main:app --host 0.0.0.0 --port 8324 --reload \
--reload-dir "$BACKEND_DIR_ABSPATH" \
"${UVICORN_EXCLUDE_ARGS[@]}"
else
python3 -m uvicorn backend.main:app --host 0.0.0.0 --port 8324
fi
+17 -9
View File
@@ -32,19 +32,27 @@ const OnboardingRoot = lazy(() =>
);
const SignInGate = lazy(() => import('./components/SignInGate'));
// Idle-prefetch the Views chunk (the App Builder page) so a first click
// on Apps in the sidebar doesn't pay 200-600ms for the webpack chunk
// download. Views/ViewEditor pulls in CodeMirror + the entire app
// runtime preview, so it's the largest lazy chunk by a wide margin.
// requestIdleCallback waits until the main thread is quiet — won't
// compete with first paint, sign-in, or onboarding boot.
// Idle-prefetch the lazy page chunks so first-click on any sidebar
// entry doesn't pay 200-600ms for the webpack chunk download. Each
// `void import('...')` triggers webpack to stream the chunk in the
// background; React.lazy returns the cached module instantly when the
// user finally navigates. We do them sequentially inside one idle
// callback to avoid all six firing at once and contending for network
// + parse time during first paint.
if (typeof window !== 'undefined') {
const prefetchViews = () => { void import('./pages/Views/Views'); };
const prefetchAll = () => {
void import('./pages/Views/Views');
void import('./pages/Skills/Skills');
void import('./pages/Tools/Tools');
void import('./pages/Modes/Modes');
void import('./pages/Customization/Customization');
void import('./pages/Analytics/Analytics');
};
const ric = (window as any).requestIdleCallback as
| ((cb: () => void, opts?: { timeout?: number }) => number)
| undefined;
if (ric) ric(prefetchViews, { timeout: 4000 });
else window.setTimeout(prefetchViews, 2000);
if (ric) ric(prefetchAll, { timeout: 4000 });
else window.setTimeout(prefetchAll, 2000);
}
import { report, getSessionTraceState, getRecentActions } from '@/shared/serviceClient';
import { useRouteTracker } from '@/shared/hooks/useRouteTracker';
@@ -18,7 +18,6 @@ import { runStep } from './ac/acRuntime';
import type { AgenticCursorHandle } from './ac/AgenticCursor';
import type { OnboardingStep } from './steps/types';
import { STEPS, findStepById } from './steps';
import { API_BASE } from '@/shared/config';
import { report } from './telemetry';
interface AttachArgs {
@@ -115,28 +114,6 @@ class OnboardingDirector {
window.addEventListener('hashchange', onRouteChange);
try {
// Fire the pre-step hook in the BACKGROUND instead of awaiting it.
// For step 6, the hook posts `seed-orchestration-demo` which can
// take 15-30s when Anthropic is rate-limiting (the meta-generation
// it triggers internally hits 429s and retries). Awaiting it
// blocked the entire AC flow — user sees no cursor, no popup,
// appears completely frozen.
//
// Now: hook fires in parallel with AC's intro animation + first
// popup + user clicks. By the time AC reaches the drag_select op
// that actually needs the stub agent (several user interactions
// in), the seed has long since completed. If the seed fails
// outright, drag_select's waitForSelector will time out into the
// normal recovery path — same as any other "target not found"
// case. Never blocks the user-visible startup.
this.runPreStepHook(step).catch((err) => {
console.warn('[onboarding] preStepHook failed', step.id, err);
report('pre_step_hook_failed', {
step_id: step.id,
error: String(err),
});
});
await runStep({
step,
spawnPoint,
@@ -156,60 +133,10 @@ class OnboardingDirector {
}
}
private async runPreStepHook(step: OnboardingStep): Promise<void> {
if (!this.store) return;
if (step.id === 'agent_control_agents') {
await this.ensureStubResearchAgent();
}
}
/**
* Step 6 needs a pre-existing "research" agent on the canvas so the
* spec's "say you already have an agent that did some work for you"
* narrative makes sense. We look for any existing session named
* "OpenSwarm research" (the seed endpoint uses that name) and only
* call seed-orchestration-demo when nothing matches — so re-running
* step 6 doesn't keep adding stub agents.
*
* In-flight dedup: rapid Show me clicks used to fire N parallel
* seed calls because Redux state didn't update until the FIRST one
* completed (and synced back via websocket). The promise cache
* collapses concurrent callers to a single backend POST.
*/
private seedInFlight: Promise<void> | null = null;
private async ensureStubResearchAgent(): Promise<void> {
if (this.seedInFlight) return this.seedInFlight;
const state = this.store!.getState();
const sessions = (state as any).agents?.sessions ?? {};
const alreadySeeded = Object.values(sessions).some(
(s: any) => s?.name === 'OpenSwarm research',
);
if (alreadySeeded) return;
const dashboardId =
(state as any).tempState?.lastDashboardId ??
Object.keys((state as any).dashboards?.items ?? {})[0] ??
null;
if (!dashboardId) return;
this.seedInFlight = (async () => {
try {
await fetch(
`${API_BASE}/dashboards/${dashboardId}/seed-orchestration-demo`,
{ method: 'POST' },
);
report('stub_research_agent_seeded', { step_id: 'agent_control_agents' });
} catch (err) {
console.warn('[onboarding] seed-orchestration-demo failed', err);
} finally {
this.seedInFlight = null;
}
})();
return this.seedInFlight;
}
// Step 6 previously triggered seed-orchestration-demo here to drop a
// stub "research" agent on the canvas. We removed it — step 6 now
// reuses the real chat the user created in step 3 as the "previous
// chat" the orchestrator bosses around, so no stub is needed.
}
export const onboardingDirector = new OnboardingDirector();
@@ -151,7 +151,19 @@ export async function runStep(args: RunStepArgs): Promise<void> {
if (!depStep) continue;
if (dep.reopen === 'walk_again') {
report('dependency_walk', { step_id: step.id, dep_id: dep.stepId });
await runOps(depStep.ops, { ...ctx, silent: true, stepId: depStep.id });
// Brief framing popup so the user knows why the cursor is
// about to walk them through a previous step's flow (e.g.
// step 5 asking step 4 to re-open a browser because they
// closed the one they spawned originally).
ac.showPopup('Quick setup before we continue.');
ctx.popupShownAt.current = performance.now();
await sleep(700);
// Non-silent walk: show popups so the user understands what
// each move_to is asking. Previously silent=true meant the
// cursor wandered through the dep's ops with no labels —
// robust but confusing. Telemetry isn't bumped for op-level
// events to avoid double-counting (silent kept for that).
await runOps(depStep.ops, { ...ctx, silent: false, stepId: depStep.id });
}
}
}
@@ -498,7 +510,12 @@ async function runOp(op: ACOp, ctx: RunContext): Promise<void> {
const r = el.getBoundingClientRect();
await ac.moveTo(Math.min(r.right - 14, r.left + r.width / 2), r.top + r.height / 2);
ac.startTracking(op.target, { x: 0, y: 0 });
await typeInto(el, op.text, { speedMs: op.speedMs });
// Resolve text — string-or-function. Function form lets a step
// pick its prompt at run-time based on current Redux state (e.g.
// step 3's YouTube vs. web-research fallback).
const resolvedText =
typeof op.text === 'function' ? op.text(ctx.store.getState()) : op.text;
await typeInto(el, resolvedText, { speedMs: op.speedMs });
// Anti-revert guard: some controlled contentEditable libraries
// re-render on their own schedule and wipe AC's typed text in
// the next React commit. Re-check the input value after a brief
@@ -512,7 +529,7 @@ async function runOp(op: ACOp, ctx: RunContext): Promise<void> {
return (e.value ?? '').trim();
return (e.textContent ?? '').trim();
};
const target = op.text.trim();
const target = resolvedText.trim();
if (target && readText(el).length < Math.floor(target.length * 0.8)) {
// Single-shot re-insert. Same path the typewriter's own
// fallback uses for under-load typing drops.
@@ -527,13 +544,13 @@ async function runOp(op: ACOp, ctx: RunContext): Promise<void> {
}
try {
document.execCommand('delete', false);
const ok = document.execCommand('insertText', false, op.text);
const ok = document.execCommand('insertText', false, resolvedText);
if (!ok) {
el.textContent = op.text;
el.textContent = resolvedText;
el.dispatchEvent(new Event('input', { bubbles: true }));
}
} catch {
el.textContent = op.text;
el.textContent = resolvedText;
el.dispatchEvent(new Event('input', { bubbles: true }));
}
}
@@ -649,7 +666,39 @@ async function runOp(op: ACOp, ctx: RunContext): Promise<void> {
return;
}
case 'wait_user': {
await waitForCondition(op.condition, signal, store, op.timeoutMs);
const first = await waitForCondition(
op.condition,
signal,
store,
op.timeoutMs,
);
// Retry-on-timeout for event_bus waits only: those fire on real
// user actions (browser:spawned, skill:installed, chat:message_sent,
// agent:attached_to_browser) — if the event never arrived the
// step's actual goal didn't happen, so silently marking the step
// done would let the user proceed against a half-broken state.
// One retry with a "didn't seem to go through" popup gives the
// user a clear chance to redo the action; if it times out a
// second time, we soft-succeed (same as before) so the step
// doesn't strand them forever.
//
// click_target + redux_predicate timeouts keep the original
// soft-success policy: the user might legitimately have done
// the underlying thing without our listener catching it.
if (first.timedOut && op.condition.kind === 'event_bus') {
report('wait_user_retry_prompted', {
step_id: ctx.stepId,
event: op.condition.event,
});
ac.showPopup("Didn't seem to go through — try again?");
ctx.popupShownAt.current = performance.now();
await waitForCondition(
op.condition,
signal,
store,
op.timeoutMs,
);
}
ac.hidePopup();
// The user just did the thing — they don't need a dwell floor on
// top of having engaged with the popup. Clearing popupShownAt
@@ -681,6 +730,23 @@ async function runOp(op: ACOp, ctx: RunContext): Promise<void> {
});
return;
}
case 'wait_for_dom': {
const timeoutMs = op.timeoutMs ?? 8000;
const POLL_MS = 100;
const startedAt = performance.now();
while (performance.now() - startedAt < timeoutMs) {
if (signal.aborted) {
throw new DOMException('aborted', 'AbortError');
}
if (document.querySelector(op.css)) return;
await sleep(POLL_MS);
}
// Soft-success on timeout — same policy as wait_user's event_bus
// path. The next op (usually move_to / type_into) will hit its own
// waitForSelector and surface a clearer error if the target is
// genuinely missing.
return;
}
case 'outro': {
await ac.fadeOut(ctx.spawnPoint);
return;
@@ -875,12 +941,16 @@ function maybeBuildExpandCustomizationOps(target: string): ACOp[] | null {
];
}
interface WaitResult {
timedOut: boolean;
}
function waitForCondition(
cond: AdvanceCondition,
signal: AbortSignal,
store: Store<RootState>,
timeoutMs?: number,
): Promise<void> {
): Promise<WaitResult> {
if (signal.aborted) {
return Promise.reject(new DOMException('aborted', 'AbortError'));
}
@@ -889,11 +959,11 @@ function waitForCondition(
let cleanup: () => void = () => {};
let timer: number | null = null;
const finish = () => {
const finish = (timedOut: boolean) => {
cleanup();
if (timer !== null) window.clearTimeout(timer);
signal.removeEventListener('abort', onAbort);
resolve();
resolve({ timedOut });
};
const onAbort = () => {
@@ -905,12 +975,10 @@ function waitForCondition(
if (timeoutMs && timeoutMs > 0) {
timer = window.setTimeout(() => {
cleanup();
signal.removeEventListener('abort', onAbort);
// Treat timeout as soft-success — the user may have done the thing
// without our condition firing (e.g. they opened the page some other
// way). Better than freezing the panel.
resolve();
// Surface the timeout to the caller so wait_user can decide
// whether to soft-succeed (the previous policy) or prompt the
// user to retry (the event_bus path — see wait_user handler).
finish(true);
}, timeoutMs);
}
@@ -923,7 +991,7 @@ function waitForCondition(
`[data-onboarding="${cond.target}"], [data-select-type="${cond.target}"]`,
)
) {
finish();
finish(false);
}
};
document.addEventListener('click', handler, true);
@@ -939,7 +1007,7 @@ function waitForCondition(
: cond.truthy
? Boolean(value)
: Boolean(value);
if (ok) finish();
if (ok) finish(false);
};
check();
const unsub = store.subscribe(check);
@@ -948,7 +1016,7 @@ function waitForCondition(
}
case 'event_bus': {
const off = onboardingBus.once(cond.event as OnboardingEvent, () =>
finish(),
finish(false),
);
cleanup = off;
return;
@@ -71,3 +71,27 @@ export function hasAnySkillInstalled(s: RootState): boolean {
if (Array.isArray(items)) return items.length > 0;
return Object.keys(items).length > 0;
}
// True if the PDF-handling skill is installed. Used by step 7 in place
// of hasAnySkillInstalled so installing any *other* skill doesn't
// auto-skip the PDF-specific install demo. Matches on id OR name OR
// command containing 'pdf' (case-insensitive) — the skill might land
// under any of those depending on how the user installed it.
export function hasPdfSkillInstalled(s: RootState): boolean {
const items = s.skills?.items as any;
const list: any[] = Array.isArray(items) ? items : Object.values(items ?? {});
return list.some((sk: any) => {
const id = (sk?.id ?? '').toString().toLowerCase();
const name = (sk?.name ?? '').toString().toLowerCase();
const cmd = (sk?.command ?? '').toString().toLowerCase();
return id.includes('pdf') || name.includes('pdf') || cmd.includes('pdf');
});
}
// True if any browser card exists on the canvas. Used by step 4 to
// auto-skip the "open a browser" walkthrough for users who already
// have one parked on their dashboard.
export function hasAnyBrowserSpawned(s: RootState): boolean {
const cards = (s as any).dashboardLayout?.browserCards ?? {};
return Object.keys(cards).length > 0;
}
@@ -33,33 +33,24 @@ export const step01: OnboardingStep = {
id: 'pro',
label: 'Open Swarm Pro subscription',
thenOps: [
{
kind: 'highlight_section',
target: S.settingsProSection,
popup: 'Pick a tier you like.',
},
{ kind: 'move_to', target: S.settingsProSection },
{ kind: 'popup', text: 'Pick a tier you like.' },
],
},
{
id: 'subscription',
label: 'I already have an AI subscription',
thenOps: [
{
kind: 'highlight_section',
target: S.settingsExternalSubs,
popup: 'Hook up your subscription here.',
},
{ kind: 'move_to', target: S.settingsExternalSubs },
{ kind: 'popup', text: 'Hook up your subscription here.' },
],
},
{
id: 'api_key',
label: 'I have an API key',
thenOps: [
{
kind: 'highlight_section',
target: S.settingsApiKeys,
popup: 'Drop your API key in.',
},
{ kind: 'move_to', target: S.settingsApiKeys },
{ kind: 'popup', text: 'Drop your API key in.' },
],
},
],
@@ -1,6 +1,6 @@
import type { OnboardingStep } from './types';
import { S } from '../selectors';
import { hasAnyToolEnabled, isYoutubeEnabled } from './skipPredicates';
import { isYoutubeEnabled } from './skipPredicates';
export const step02: OnboardingStep = {
id: 'enable_actions',
@@ -10,7 +10,11 @@ export const step02: OnboardingStep = {
description: 'Allow agents to work across your apps.',
videoSrc: './onboarding-videos/v2/02.mp4',
videoDurationLabel: '0:24',
skipIf: hasAnyToolEnabled,
// Narrowed from hasAnyToolEnabled → isYoutubeEnabled so users with
// an unrelated tool already on (e.g. Slack, Reddit) still get walked
// through enabling YouTube — step 3's hardcoded YouTube-summary
// prompt would otherwise hit a disabled MCP and stall.
skipIf: isYoutubeEnabled,
ops: [
{ kind: 'move_to', target: S.sidebarActions },
{ kind: 'popup', text: 'Peek at Actions.' },
@@ -1,6 +1,18 @@
import type { OnboardingStep } from './types';
import { S } from '../selectors';
import { hasAnyAgentLaunched } from './skipPredicates';
import { hasAnyAgentLaunched, isYoutubeEnabled } from './skipPredicates';
// Primary demo: summarize a YouTube video — requires the YouTube
// transcript MCP, which step 2 enables. If a user reaches step 3 with
// YouTube not enabled (they skipped step 2's flow, dismissed it, or
// toggled YouTube back off), the agent would hang trying to call a
// missing MCP. The fallback prompt uses the agent's built-in web tools
// to do live research — same "agent does real work" demo, no MCP
// dependency.
const YOUTUBE_PROMPT =
'What is this youtube video about: https://youtu.be/_NKj8KQMY-k?si=rEk4KO2bOpa5Vo0z. Do not use browser agents.';
const FALLBACK_PROMPT =
'Find the latest news about AI from the web and give me a short summary.';
export const step03: OnboardingStep = {
id: 'launch_agent',
@@ -24,12 +36,12 @@ export const step03: OnboardingStep = {
{
kind: 'type_into',
target: S.chatInput,
// Anti-browser-agent directive: the YouTube summary can be
// answered entirely from the youtube transcript MCP without
// spawning a browser-agent. Browser agents misbehave under
// load (ReportProgress violation loops, rate-limit retries)
// and tank dashboard responsiveness. The transcript is plenty.
text: 'What is this youtube video about: https://youtu.be/_NKj8KQMY-k?si=rEk4KO2bOpa5Vo0z. Do not use browser agents.',
// Anti-browser-agent directive on the YouTube path: the summary
// can be answered entirely from the youtube transcript MCP, and
// browser agents misbehave under load. The fallback path
// intentionally USES web tools — that's the whole point of the
// fallback (no MCP needed, agent still demonstrates real work).
text: (state) => (isYoutubeEnabled(state) ? YOUTUBE_PROMPT : FALLBACK_PROMPT),
speedMs: 12,
},
// Auto-send the prompt — same pattern as steps 5/6/8. Without this,
@@ -1,5 +1,6 @@
import type { OnboardingStep } from './types';
import { S } from '../selectors';
import { hasAnyBrowserSpawned } from './skipPredicates';
export const step04: OnboardingStep = {
id: 'use_browser',
@@ -10,6 +11,10 @@ export const step04: OnboardingStep = {
'No more jumping between apps. You and your agents work in one place.',
videoSrc: './onboarding-videos/v2/04.mp4',
videoDurationLabel: '0:18',
// Auto-skip if the user already has a browser on canvas — re-running
// "open another browser" is just noise when they've clearly already
// discovered the feature.
skipIf: hasAnyBrowserSpawned,
// Runtime auto-prepends a "click into a dashboard" hop when the user
// isn't already on a #/dashboards/:id route. No need to repeat that in
// ops — the previous version of this step pointed at the section
@@ -10,13 +10,17 @@ export const step06: OnboardingStep = {
videoSrc: './onboarding-videos/v2/06.mp4',
videoDurationLabel: '0:34',
requiresDashboard: true,
// Reuses the chat the user launched back in step 3 (the YouTube /
// web-research agent) as the "previous chat." Step 5's
// dependsOn-walk pattern would be appropriate here too, but
// pragmatically: by step 6 the user has already created at least one
// chat (step 3 marks itself done on chat:message_sent), so we just
// frame the existing chat as the helper instead of seeding a stub
// via seed-orchestration-demo.
ops: [
// The OnboardingRoot pre-runs `seed-orchestration-demo` before a step-6
// start so a stub "research" agent already exists on the canvas. The
// popup below tells the user to imagine they made it themselves.
{
kind: 'popup',
text: "Pretend this chat already did the homework. Now we'll have a fresh one boss it around.",
text: "Remember the chat you just made? We'll have a fresh one boss it around.",
},
{ kind: 'move_to', target: S.newAgentButton },
{ kind: 'popup', text: "Make a new chat. This one's the boss." },
@@ -34,15 +38,15 @@ export const step06: OnboardingStep = {
},
// Same auto-fit as step 5: the new orchestrator chat triggers
// Dashboard's autoFocusSessionId, which often pushes the older
// research card off-screen. Click fit-to-view first so both cards
// are visible together for the drag-select demo.
// chat off-screen. Click fit-to-view first so both cards are
// visible together for the drag-select demo.
{ kind: 'move_to', target: S.canvasFitToView },
{ kind: 'click', target: S.canvasFitToView, simulate: true },
{ kind: 'delay', ms: 350 },
{ kind: 'drag_select', target: 'agent-card' },
{
kind: 'popup',
text: 'Now you try! Drag a box around the chat to make it a helper.',
text: 'Now you try! Drag a box around the older chat to make it a helper.',
},
{
kind: 'wait_user',
@@ -55,7 +59,10 @@ export const step06: OnboardingStep = {
{
kind: 'type_into',
target: S.chatInput,
text: 'Create a pdf report of the research and save it to my downloads',
// Phrased to work against EITHER prompt step 3 sent — the
// YouTube summary OR the web-research fallback. "What it dug
// up" covers both without naming the source.
text: 'Turn what it dug up into a PDF report and save it to my downloads.',
speedMs: 12,
},
{ kind: 'move_to', target: S.chatSendButton },
@@ -1,6 +1,6 @@
import type { OnboardingStep } from './types';
import { S } from '../selectors';
import { hasAnySkillInstalled } from './skipPredicates';
import { hasPdfSkillInstalled } from './skipPredicates';
export const step07: OnboardingStep = {
id: 'install_skill',
@@ -10,7 +10,11 @@ export const step07: OnboardingStep = {
description: 'Teach agents how to handle specific tasks.',
videoSrc: './onboarding-videos/v2/07.mp4',
videoDurationLabel: '0:24',
skipIf: hasAnySkillInstalled,
// Narrowed from hasAnySkillInstalled → hasPdfSkillInstalled so a
// user who's installed any *other* skill still gets walked through
// the PDF-install demo (which is what the step's targets + popups
// are pointed at).
skipIf: hasPdfSkillInstalled,
ops: [
{ kind: 'move_to', target: S.sidebarSkills },
{ kind: 'popup', text: 'Wander into Skills.' },
@@ -23,24 +23,26 @@ export const step08: OnboardingStep = {
condition: { kind: 'click_target', target: S.appsNewButton },
},
// After clicking +, the /apps/new route mounts ViewEditor which
// asynchronously renders AgentChat in the left pane (model probe
// + initial fetch). Without this delay AC tries to type into an
// input that's either not yet mounted or mounted-but-not-wired
// to the App Builder agent's state machine. Characters land in
// the DOM but get discarded on first render commit.
//
// 1500ms covers the typical mount + model probe round-trip even
// under main-thread starvation from concurrent agent streams.
// waitForSelector below ALSO retries on its own, so this is a
// belt-and-suspenders preflight, not the primary wait.
// asynchronously renders AgentChat in the left pane (model probe +
// initial fetch). The chat-input data-onboarding marker can land
// on a DIFFERENT agent's chat (one of the dashboard cards) before
// the App Builder's own scope mounts, so we wait for the scoped
// marker specifically. wait_for_dom polls every 100ms up to 8s —
// instant on warm starts, patient on cold ones. Replaces the prior
// fixed 1500ms delay that under-fit slow boots and added latency
// on fast ones.
{
kind: 'popup',
text: 'Loading the App Builder...',
},
{ kind: 'delay', ms: 1500 },
// The App Builder chat lives in the left pane on /apps/new — a
// regular ChatInput instance, so data-onboarding="chat-input"
// resolves to it.
{
kind: 'wait_for_dom',
css: '[data-onboarding-scope="app-builder"] [data-onboarding="chat-input"]',
timeoutMs: 8000,
},
// The App Builder chat lives in the left pane on /apps/new — the
// chat-input selector resolves to it via the App Builder scope
// priority in resolveSelector.
{ kind: 'move_to', target: S.chatInput },
{
kind: 'type_into',
@@ -23,11 +23,26 @@ export type ACOp =
| { kind: 'popup'; text: string; cta?: string }
| { kind: 'multi_choice'; opId: string; question: string; options: ACMultiChoiceOption[] }
| { kind: 'highlight_section'; target: Selector; popup?: string; durationMs?: number }
| { kind: 'type_into'; target: Selector; text: string; speedMs?: number }
| {
kind: 'type_into';
target: Selector;
// String for static text; function for runtime branching (e.g. step
// 3 picks YouTube prompt if isYoutubeEnabled, else a web-research
// fallback). Evaluated once at op-execution time against current
// Redux state — not reactive to subsequent state changes.
text: string | ((state: RootState) => string);
speedMs?: number;
}
| { kind: 'click'; target: Selector; simulate?: boolean }
| { kind: 'drag_select'; target: Selector }
| { kind: 'wait_user'; condition: AdvanceCondition; hint?: string; timeoutMs?: number }
| { kind: 'delay'; ms: number }
// Poll a raw CSS selector (not a data-onboarding shorthand) until it
// appears in the DOM, up to `timeoutMs`. Used by step 8 to wait for
// the App Builder's scoped chat-input to mount before typing into it
// (previously a fixed 1500ms delay that under-fit slow cold-starts
// and over-fit warm ones).
| { kind: 'wait_for_dom'; css: string; timeoutMs?: number }
| { kind: 'outro' };
export type AdvanceCondition =
+91 -2
View File
@@ -1,4 +1,5 @@
import React, { useState, useMemo, useEffect, useRef, useCallback, PointerEvent as ReactPointerEvent } from 'react';
import { useNavigate } from 'react-router-dom';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import CircularProgress from '@mui/material/CircularProgress';
@@ -22,11 +23,13 @@ import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile';
import FolderIcon from '@mui/icons-material/Folder';
import AddIcon from '@mui/icons-material/Add';
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
import VisibilityIcon from '@mui/icons-material/Visibility';
import VisibilityOffIcon from '@mui/icons-material/VisibilityOff';
import Collapse from '@mui/material/Collapse';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { createDraftSession, removeDraftSession, fetchSession } from '@/shared/state/agentsSlice';
import { createOutput, updateOutput, Output, SERVE_BASE } from '@/shared/state/outputsSlice';
import { createOutput, updateOutput, fetchOutputs, Output, SERVE_BASE } from '@/shared/state/outputsSlice';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import AgentChat from '../AgentChat/AgentChat';
import RefreshIcon from '@mui/icons-material/Refresh';
@@ -40,6 +43,24 @@ import { API_BASE, getAuthToken } from '@/shared/config';
import { onboardingBus } from '@/app/components/Onboarding/eventBus';
const WORKSPACE_API = `${API_BASE}/outputs/workspace`;
// 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
// both filtered out. User can flip `showHidden` to bypass. Anything
// the agent legitimately writes lives in `src/`, `public/`,
// `backend/`, `package.json`, `vite.config.ts`, `.env`, `README.md`
// — none of those collide with this set.
const HIDDEN_PATH_SEGMENTS = new Set<string>([
'node_modules',
'.vite-cache',
'.vite',
'.git',
'dist',
'.next',
'__pycache__',
'.venv',
]);
// Workspace state poll cadence. While the agent is actively writing
// files we want a snappy 2s so the file tree / code panes stay in
// sync. Once the agent goes idle there's no reason to keep hammering
@@ -215,6 +236,7 @@ interface Props {
const ViewEditor: React.FC<Props> = ({ output }) => {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const navigate = useNavigate();
const [createdId, setCreatedId] = useState<string | null>(null);
const createdIdRef = useRef<string | null>(null);
@@ -240,6 +262,9 @@ const ViewEditor: React.FC<Props> = ({ output }) => {
const [activeTab, setActiveTab] = useState(TAB_PREVIEW);
const [activeFile, setActiveFile] = useState('index.html');
// When false, HIDDEN_PATH_SEGMENTS get filtered out of the file tree.
// Persisted to the workspace's localStorage so toggle survives reload.
const [showHidden, setShowHidden] = useState(false);
const autoSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Skip preview reloads when nothing the user can SEE changed.
// The iframe renders index.html; if a save only touched SKILL.md or
@@ -421,6 +446,30 @@ const ViewEditor: React.FC<Props> = ({ output }) => {
});
const data = await res.json();
setWorkspacePath(data.path);
// Backend creates an Output record at seed time for
// webapp_template workspaces (workspace_id wired up, name
// "Untitled App"). Adopt that id NOW so:
// 1. The Apps sidebar refresh below shows the in-progress app
// immediately — users who navigate away can find it again.
// 2. Later autosaves take the updateOutput branch (using
// `output?.id ?? createdIdRef.current`) instead of trying
// to recreate.
// Old flat-mode seeds don't return output_id; that path keeps
// its previous behavior (create-on-first-autosave).
if (typeof data?.output_id === 'string' && data.output_id) {
createdIdRef.current = data.output_id;
setCreatedId(data.output_id);
// Refresh the Apps list so the new app shows up in the sidebar
// before the user navigates away from /apps/new.
dispatch(fetchOutputs());
// Replace /apps/new in the URL with /apps/{output_id} so a
// reload (or back-button return) lands back on the same
// workspace instead of spinning up yet another fresh seed.
// Use replace so /apps/new doesn't pile up in history.
if (window.location.hash.includes('/apps/new')) {
navigate(`/apps/${data.output_id}`, { replace: true });
}
}
const action = dispatch(createDraftSession({
mode: 'view-builder',
setActive: false,
@@ -811,7 +860,31 @@ const ViewEditor: React.FC<Props> = ({ output }) => {
? undefined
: (frontendUrl ?? (workspaceId ? `${SERVE_BASE}/workspace/${workspaceId}/serve/index.html` : undefined));
const filePaths = useMemo(() => Object.keys(files).filter(p => p !== 'meta.json' && p !== 'SKILL.md').sort(), [files]);
// 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
// MUI/icons chunks the agent + user have no reason to look at. They
// can still be opened via the Workspace folder in Finder if needed.
// Single-source-of-truth predicate so the file list, tree, and
// open-file routing all agree on what counts as visible.
const isHiddenPath = useCallback((p: string): boolean => {
if (showHidden) return false;
// Treat exact basenames + any nested occurrence as hidden.
const segments = p.split('/');
for (const seg of segments) {
if (HIDDEN_PATH_SEGMENTS.has(seg)) return true;
}
return false;
}, [showHidden]);
const filePaths = useMemo(
() =>
Object.keys(files)
.filter((p) => p !== 'meta.json' && p !== 'SKILL.md')
.filter((p) => !isHiddenPath(p))
.sort(),
[files, isHiddenPath],
);
const fileTree = useMemo(() => buildFileTree(filePaths), [filePaths]);
const updateFile = useCallback((path: string, content: string) => {
@@ -1144,6 +1217,22 @@ const ViewEditor: React.FC<Props> = ({ output }) => {
>
Files
</Typography>
<Tooltip
title={showHidden ? 'Hide build/install dirs' : 'Show hidden (node_modules, .vite-cache, etc.)'}
placement="top"
>
<IconButton
size="small"
onClick={() => setShowHidden((v) => !v)}
sx={{ p: 0.25, color: c.text.ghost, '&:hover': { color: c.accent.primary } }}
>
{showHidden ? (
<VisibilityOffIcon sx={{ fontSize: 14 }} />
) : (
<VisibilityIcon sx={{ fontSize: 14 }} />
)}
</IconButton>
</Tooltip>
<Tooltip title="New file" placement="top">
<IconButton
size="small"
+5
View File
@@ -85,6 +85,11 @@ if [ ! -f "$UV_BIN_DIR/uvx" ]; then
fi
# --- Start backend ---
# Mark this as a dev launch so backend/run.sh enables --reload. Packaged
# builds never run this top-level script (Electron spawns backend
# directly), so the env stays unset in production and uvicorn boots in
# its leaner non-reload mode.
export OPENSWARM_DEV=1
echo -e "${BLUE}${BOLD}[backend]${RESET} Starting backend server..."
bash "$PROJECT_ROOT/backend/run.sh" > >(
while IFS= read -r line; do