From c0c52e33d6ad745710428f4c9253ae9ce2ec253b Mon Sep 17 00:00:00 2001 From: ciregenz Date: Tue, 12 May 2026 13:11:56 -0700 Subject: [PATCH] [eric] App Builder saves to sidebar on seed + onboarding fallbacks (auto-skip rules, fallback prompts, retry popup) --- backend/apps/outputs/app_builder_skill.md | 37 +++++ backend/apps/outputs/outputs.py | 133 ++++++++++++++++-- .../apps/outputs/view_builder_templates.py | 24 +++- .../outputs/webapp_template/backend/run.sh | 9 +- .../webapp_template/frontend/vite.config.ts | 51 +++++-- backend/run.sh | 19 ++- frontend/src/app/Main.tsx | 26 ++-- .../Onboarding/OnboardingDirector.ts | 81 +---------- .../app/components/Onboarding/ac/acRuntime.ts | 106 +++++++++++--- .../Onboarding/steps/skipPredicates.ts | 24 ++++ .../Onboarding/steps/step01_connectModel.ts | 21 +-- .../Onboarding/steps/step02_enableActions.ts | 8 +- .../Onboarding/steps/step03_launchAgent.ts | 26 +++- .../Onboarding/steps/step04_useBrowser.ts | 5 + .../steps/step06_agentControlAgents.ts | 23 +-- .../Onboarding/steps/step07_installSkill.ts | 8 +- .../Onboarding/steps/step08_makeApp.ts | 30 ++-- .../app/components/Onboarding/steps/types.ts | 17 ++- frontend/src/app/pages/Views/ViewEditor.tsx | 93 +++++++++++- run.sh | 5 + 20 files changed, 556 insertions(+), 190 deletions(-) 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 + >( while IFS= read -r line; do