diff --git a/backend/apps/outputs/app_builder_skill.md b/backend/apps/outputs/app_builder_skill.md
index 63edbcd6..cc18beb3 100644
--- a/backend/apps/outputs/app_builder_skill.md
+++ b/backend/apps/outputs/app_builder_skill.md
@@ -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
+() 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.
---
diff --git a/backend/apps/outputs/outputs.py b/backend/apps/outputs/outputs.py
index 01f34fff..58f92ef7 100644
--- a/backend/apps/outputs/outputs.py
+++ b/backend/apps/outputs/outputs.py
@@ -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:
diff --git a/backend/apps/outputs/view_builder_templates.py b/backend/apps/outputs/view_builder_templates.py
index 65ecb8ed..293494b0 100644
--- a/backend/apps/outputs/view_builder_templates.py
+++ b/backend/apps/outputs/view_builder_templates.py
@@ -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],
diff --git a/backend/apps/outputs/webapp_template/backend/run.sh b/backend/apps/outputs/webapp_template/backend/run.sh
index 77f617e1..fff84a3c 100755
--- a/backend/apps/outputs/webapp_template/backend/run.sh
+++ b/backend/apps/outputs/webapp_template/backend/run.sh
@@ -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}"
diff --git a/backend/apps/outputs/webapp_template/frontend/vite.config.ts b/backend/apps/outputs/webapp_template/frontend/vite.config.ts
index 92e68d88..ae0498af 100644
--- a/backend/apps/outputs/webapp_template/frontend/vite.config.ts
+++ b/backend/apps/outputs/webapp_template/frontend/vite.config.ts
@@ -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'] }),
diff --git a/backend/run.sh b/backend/run.sh
index 07966df6..b62bdc31 100755
--- a/backend/run.sh
+++ b/backend/run.sh
@@ -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
diff --git a/frontend/src/app/Main.tsx b/frontend/src/app/Main.tsx
index 6c869a72..4e60abee 100644
--- a/frontend/src/app/Main.tsx
+++ b/frontend/src/app/Main.tsx
@@ -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';
diff --git a/frontend/src/app/components/Onboarding/OnboardingDirector.ts b/frontend/src/app/components/Onboarding/OnboardingDirector.ts
index 10727da3..880c3c3d 100644
--- a/frontend/src/app/components/Onboarding/OnboardingDirector.ts
+++ b/frontend/src/app/components/Onboarding/OnboardingDirector.ts
@@ -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 {
- 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 | null = null;
-
- private async ensureStubResearchAgent(): Promise {
- 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();
diff --git a/frontend/src/app/components/Onboarding/ac/acRuntime.ts b/frontend/src/app/components/Onboarding/ac/acRuntime.ts
index 8485014c..a58055d9 100644
--- a/frontend/src/app/components/Onboarding/ac/acRuntime.ts
+++ b/frontend/src/app/components/Onboarding/ac/acRuntime.ts
@@ -151,7 +151,19 @@ export async function runStep(args: RunStepArgs): Promise {
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 {
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 {
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 {
}
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 {
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 {
});
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,
timeoutMs?: number,
-): Promise {
+): Promise {
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;
diff --git a/frontend/src/app/components/Onboarding/steps/skipPredicates.ts b/frontend/src/app/components/Onboarding/steps/skipPredicates.ts
index 4873f7de..4da11b29 100644
--- a/frontend/src/app/components/Onboarding/steps/skipPredicates.ts
+++ b/frontend/src/app/components/Onboarding/steps/skipPredicates.ts
@@ -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;
+}
diff --git a/frontend/src/app/components/Onboarding/steps/step01_connectModel.ts b/frontend/src/app/components/Onboarding/steps/step01_connectModel.ts
index 445e49fb..b1183d7e 100644
--- a/frontend/src/app/components/Onboarding/steps/step01_connectModel.ts
+++ b/frontend/src/app/components/Onboarding/steps/step01_connectModel.ts
@@ -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.' },
],
},
],
diff --git a/frontend/src/app/components/Onboarding/steps/step02_enableActions.ts b/frontend/src/app/components/Onboarding/steps/step02_enableActions.ts
index c273af29..ff7fb145 100644
--- a/frontend/src/app/components/Onboarding/steps/step02_enableActions.ts
+++ b/frontend/src/app/components/Onboarding/steps/step02_enableActions.ts
@@ -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.' },
diff --git a/frontend/src/app/components/Onboarding/steps/step03_launchAgent.ts b/frontend/src/app/components/Onboarding/steps/step03_launchAgent.ts
index 84098eb6..4b079a35 100644
--- a/frontend/src/app/components/Onboarding/steps/step03_launchAgent.ts
+++ b/frontend/src/app/components/Onboarding/steps/step03_launchAgent.ts
@@ -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,
diff --git a/frontend/src/app/components/Onboarding/steps/step04_useBrowser.ts b/frontend/src/app/components/Onboarding/steps/step04_useBrowser.ts
index afcad6c3..2f7e56e7 100644
--- a/frontend/src/app/components/Onboarding/steps/step04_useBrowser.ts
+++ b/frontend/src/app/components/Onboarding/steps/step04_useBrowser.ts
@@ -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
diff --git a/frontend/src/app/components/Onboarding/steps/step06_agentControlAgents.ts b/frontend/src/app/components/Onboarding/steps/step06_agentControlAgents.ts
index bb5d0cad..f97f16da 100644
--- a/frontend/src/app/components/Onboarding/steps/step06_agentControlAgents.ts
+++ b/frontend/src/app/components/Onboarding/steps/step06_agentControlAgents.ts
@@ -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 },
diff --git a/frontend/src/app/components/Onboarding/steps/step07_installSkill.ts b/frontend/src/app/components/Onboarding/steps/step07_installSkill.ts
index 43e8026a..3292642f 100644
--- a/frontend/src/app/components/Onboarding/steps/step07_installSkill.ts
+++ b/frontend/src/app/components/Onboarding/steps/step07_installSkill.ts
@@ -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.' },
diff --git a/frontend/src/app/components/Onboarding/steps/step08_makeApp.ts b/frontend/src/app/components/Onboarding/steps/step08_makeApp.ts
index 00567373..b273f5ea 100644
--- a/frontend/src/app/components/Onboarding/steps/step08_makeApp.ts
+++ b/frontend/src/app/components/Onboarding/steps/step08_makeApp.ts
@@ -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',
diff --git a/frontend/src/app/components/Onboarding/steps/types.ts b/frontend/src/app/components/Onboarding/steps/types.ts
index 93d5e0bc..43a4e252 100644
--- a/frontend/src/app/components/Onboarding/steps/types.ts
+++ b/frontend/src/app/components/Onboarding/steps/types.ts
@@ -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 =
diff --git a/frontend/src/app/pages/Views/ViewEditor.tsx b/frontend/src/app/pages/Views/ViewEditor.tsx
index 6752997c..894bdee6 100644
--- a/frontend/src/app/pages/Views/ViewEditor.tsx
+++ b/frontend/src/app/pages/Views/ViewEditor.tsx
@@ -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([
+ '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 = ({ output }) => {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
+ const navigate = useNavigate();
const [createdId, setCreatedId] = useState(null);
const createdIdRef = useRef(null);
@@ -240,6 +262,9 @@ const ViewEditor: React.FC = ({ 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 | 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 = ({ 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 = ({ 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 = ({ output }) => {
>
Files
+
+ setShowHidden((v) => !v)}
+ sx={{ p: 0.25, color: c.text.ghost, '&:hover': { color: c.accent.primary } }}
+ >
+ {showHidden ? (
+
+ ) : (
+
+ )}
+
+
>(
while IFS= read -r line; do