mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-28 04:24:51 +02:00
[eric] App Builder saves to sidebar on seed + onboarding fallbacks (auto-skip rules, fallback prompts, retry popup)
This commit is contained in:
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user