defluff (frontend + backend): strip em-dashes + shorten docstrings + drop dead UI files (cosmetic only, no schedule code)

This commit is contained in:
ciregenz
2026-05-20 05:36:17 -07:00
parent 5b0c6e1df3
commit f59bf0db9b
118 changed files with 853 additions and 4202 deletions
+6 -90
View File
@@ -34,10 +34,6 @@ import ApprovalBar, { BatchApprovalBar, parseMcpToolName, useMcpToolMeta, getToo
import GlobalSearchPalette from '@/app/components/GlobalSearchPalette';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type IslandState = 'idle' | 'compact' | 'compact-actionable' | 'expanded';
interface SessionApprovalGroup {
@@ -61,17 +57,9 @@ const STATUS_CONFIG: Record<string, { label: string; tokenKey?: string }> = {
stopped: { label: 'Stopped', tokenKey: 'info' },
};
// ---------------------------------------------------------------------------
// Spring configs
// ---------------------------------------------------------------------------
const SPRING_LAYOUT = { type: 'spring' as const, stiffness: 400, damping: 30 };
const SPRING_BOUNCE = { type: 'spring' as const, stiffness: 500, damping: 25 };
// ---------------------------------------------------------------------------
// Sub-components
// ---------------------------------------------------------------------------
const StatusDot: React.FC<{ status: string; c: ReturnType<typeof useClaudeTokens> }> = ({ status, c }) => {
const cfg = STATUS_CONFIG[status];
const color = cfg?.tokenKey ? (c.status as any)[cfg.tokenKey] : c.text.ghost;
@@ -172,10 +160,6 @@ const AgentStatusRow: React.FC<{
);
};
// ---------------------------------------------------------------------------
// Compact activity indicator — subtle breathing dot
// ---------------------------------------------------------------------------
const ActivityIndicator: React.FC<{ c: ReturnType<typeof useClaudeTokens> }> = ({ c }) => (
<Box
sx={{
@@ -193,20 +177,7 @@ const ActivityIndicator: React.FC<{ c: ReturnType<typeof useClaudeTokens> }> = (
/>
);
// ---------------------------------------------------------------------------
// Memoized session projection
// ---------------------------------------------------------------------------
//
// DynamicIsland only reads name / status / dashboard_id / pending_approvals
// per session. We project to a stable shape so identity persists across
// streamingMessage deltas (which mutate state.streaming, not state.agents,
// but still trigger Immer to swap the agents root reference any time
// agentsSlice runs (fine in theory, but selector consumers re-fire).
//
// Per-session cache: when a session's relevant fields haven't moved,
// return the SAME inner object reference, so the outer dict can be
// dropped on shallowEqual if its key set + per-session refs match.
// Memoized session projection so identity persists across streamingMessage deltas; shallowEqual works.
type DiSession = {
id: string;
name: string;
@@ -245,8 +216,7 @@ const selectDynamicIslandSessions = createSelector(
out[sid] = next;
}
}
// Evict cache entries for sessions that disappeared. Without this,
// long sessions of dashboard switching slowly accumulate dead refs.
// Evict cache entries for vanished sessions or refs accumulate during dashboard switching.
for (const cached of _diSessionCache.keys()) {
if (!liveIds.has(cached)) _diSessionCache.delete(cached);
}
@@ -254,26 +224,13 @@ const selectDynamicIslandSessions = createSelector(
},
);
// ---------------------------------------------------------------------------
// Main component
// ---------------------------------------------------------------------------
const DynamicIsland: React.FC = () => {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const navigate = useNavigate();
const islandRef = useRef<HTMLDivElement>(null);
// Read the whole sessions dict, but memoize its projection so the
// useSelector only emits a new value when one of the four fields we
// actually consume (name/status/dashboard_id/pending_approvals)
// changes for SOME session. createSelector caches both the inner
// per-session shape AND the outer dict, so re-runs return the same
// reference when nothing relevant moved, even though Immer flips
// the top-level dict ref on every streamed character elsewhere.
// shallowEqual: createSelector returns a fresh outer dict object on
// each re-run, but the inner refs are cached so when nothing relevant
// moved, key-by-key comparison short-circuits the re-render.
// Memoized projection + shallowEqual; only re-renders when one of the four fields actually changes.
const sessions = useAppSelector(selectDynamicIslandSessions, shallowEqual);
const history = useAppSelector((state) => state.agents.history, shallowEqual);
const trackedIds = useAppSelector((state) => state.agents.trackedNotificationIds, shallowEqual);
@@ -281,7 +238,7 @@ const DynamicIsland: React.FC = () => {
const [userExpanded, setUserExpanded] = useState(false);
const [searchOpen, setSearchOpen] = useState(false);
// Global Cmd/Ctrl+K open search palette from anywhere.
// Global Cmd/Ctrl+K opens search palette from anywhere.
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 'k') {
@@ -293,24 +250,13 @@ const DynamicIsland: React.FC = () => {
return () => window.removeEventListener('keydown', handler);
}, []);
// Global Cmd/Ctrl+L clear the chat (Claude Code convention). Resolves
// the target session in priority order:
// 1) the session whose chat input currently has focus (when typing inside
// a contentEditable card body, the data-session-id climbs the DOM)
// 2) state.agents.activeSessionId (last touched chat)
// 3) a single visible session if there's exactly one
// No-op if none of those resolve. Hits the same /clear endpoint as the
// /clear slash command and dispatches clearSessionMessages so the visible
// transcript matches the now-empty SDK context.
// Cmd/Ctrl+L: clear the chat (focused card > activeSessionId > sole session); same as /clear.
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (!(e.metaKey || e.ctrlKey)) return;
if (e.shiftKey || e.altKey) return;
if (e.key.toLowerCase() !== 'l') return;
// Walk up from activeElement looking for an agent-card marker.
// Falls back to Redux's activeSessionId, then to the only session
// if it's unambiguous.
let target: string | null = null;
const ae = document.activeElement as HTMLElement | null;
if (ae) {
@@ -347,8 +293,6 @@ const DynamicIsland: React.FC = () => {
return () => window.removeEventListener('keydown', handler);
}, [dispatch]);
// ---- Derived data ----
const groups: SessionApprovalGroup[] = useMemo(() => {
const result: SessionApprovalGroup[] = [];
for (const [sessionId, session] of Object.entries(sessions)) {
@@ -429,8 +373,6 @@ const DynamicIsland: React.FC = () => {
);
}, [groups]);
// ---- Island state machine ----
const islandState: IslandState = useMemo(() => {
if (userExpanded && (hasAgents || hasApprovals)) return 'expanded';
if (hasApprovals && hasOnlyQuestionApprovals) return 'expanded';
@@ -445,8 +387,6 @@ const DynamicIsland: React.FC = () => {
}
}, [hasAgents, hasApprovals]);
// ---- Click outside to collapse ----
useEffect(() => {
if (islandState !== 'expanded') return;
const handler = (e: MouseEvent) => {
@@ -458,8 +398,6 @@ const DynamicIsland: React.FC = () => {
return () => document.removeEventListener('mousedown', handler);
}, [islandState]);
// ---- Callbacks ----
const onApprove = useCallback(
(requestId: string, updatedInput?: Record<string, any>) => {
dispatch(handleApproval({ requestId, behavior: 'allow', updatedInput }));
@@ -524,8 +462,6 @@ const DynamicIsland: React.FC = () => {
}
}, [islandState]);
// ---- Styling — uses the same neutral palette as the rest of the UI ----
const islandWidth = islandState === 'idle'
? 200
: islandState === 'compact'
@@ -542,8 +478,6 @@ const DynamicIsland: React.FC = () => {
? c.shadow.sm
: c.shadow.md;
// ---- Compact summary text ----
const compactText = useMemo(() => {
const parts: string[] = [];
if (activeAgents.length > 0) {
@@ -562,8 +496,6 @@ const DynamicIsland: React.FC = () => {
}
`, [c.status.warning]);
// ---- Render ----
return (
<>
{islandState === 'compact-actionable' && <style>{glowKeyframes}</style>}
@@ -655,10 +587,6 @@ const DynamicIsland: React.FC = () => {
);
};
// ---------------------------------------------------------------------------
// Idle pill — clickable search bar (opens GlobalSearchPalette).
// ---------------------------------------------------------------------------
const isMac = typeof navigator !== 'undefined' && /Mac|iPod|iPhone|iPad/.test(navigator.platform);
const SEARCH_HOTKEY = isMac ? '⌘K' : 'Ctrl+K';
@@ -713,10 +641,6 @@ const IdlePill: React.FC<{ c: ReturnType<typeof useClaudeTokens>; onClick: () =>
</motion.div>
);
// ---------------------------------------------------------------------------
// Compact pill
// ---------------------------------------------------------------------------
const CompactPill: React.FC<{
c: ReturnType<typeof useClaudeTokens>;
text: string;
@@ -769,10 +693,6 @@ const CompactPill: React.FC<{
</motion.div>
);
// ---------------------------------------------------------------------------
// Compact-actionable pill — single approval with icon + name + approve/deny
// ---------------------------------------------------------------------------
const CompactActionablePill: React.FC<{
c: ReturnType<typeof useClaudeTokens>;
request: ApprovalRequest;
@@ -854,7 +774,7 @@ const CompactActionablePill: React.FC<{
+{remainingCount - 1}
</Typography>
)}
<Tooltip title={isIntervention ? 'Done continue' : 'Approve'} arrow>
<Tooltip title={isIntervention ? 'Done, continue' : 'Approve'} arrow>
<IconButton
size="small"
onClick={(e) => { e.stopPropagation(); onApprove(request.id); }}
@@ -926,10 +846,6 @@ const CompactActionablePill: React.FC<{
);
};
// ---------------------------------------------------------------------------
// Expanded card
// ---------------------------------------------------------------------------
const ExpandedCard: React.FC<{
c: ReturnType<typeof useClaudeTokens>;
groups: SessionApprovalGroup[];
@@ -71,10 +71,7 @@ export const ElementSelectionProvider: React.FC<{ children: React.ReactNode }> =
if (existing.some((e) => e.id === el.id)) return prev;
return { ...prev, [ownerId]: [...existing, el] };
});
// Same onboarding-bus emit as addElementForOwner. Drag-select goes
// through THIS path (via useDomElementSelector → ctx.addSelectedElement),
// not addElementForOwner — so without this branch, step 5 / 6's
// wait-for-attached event never fires when the user actually drags.
// Drag-select also emits agent:attached_to_browser; addElementForOwner alone misses this path.
if (el.semanticType === 'browser-card' || el.semanticType === 'agent-card') {
onboardingBus.emit('agent:attached_to_browser');
}
@@ -115,11 +112,7 @@ export const ElementSelectionProvider: React.FC<{ children: React.ReactNode }> =
if (existing.some((e) => e.semanticData?.selectId === el.semanticData?.selectId)) return prev;
return { ...prev, [ownerId]: [...existing, el] };
});
// Surface the attachment to the onboarding bus. Step 5 ("have an
// agent use the browser") and step 6 ("have an agent control other
// agents") both wait on this event after the user repeats the
// drag-select gesture. Both element kinds (browser-card / agent-card)
// resolve the same wait — the runtime doesn't differentiate.
// Onboarding steps 5/6 wait on agent:attached_to_browser; both kinds resolve the same wait.
if (
el.semanticType === 'browser-card' ||
el.semanticType === 'agent-card'
+5 -13
View File
@@ -2,11 +2,11 @@ import React from 'react';
import { report, getRecentActions } from '@/shared/serviceClient';
interface Props {
/** Friendly title for the fallback card. Default: "Something broke." */
/** Title for the fallback card. */
title?: string;
/** Optional reset hook — if provided, the Reload button calls this instead of reloading the window. */
/** If provided, Reload calls this instead of reloading the window. */
onReset?: () => void;
/** Where the boundary lives, for support ("root" | "page:tools" | etc.). */
/** Where the boundary lives, for support ("root", "page:tools", etc.). */
scope?: string;
children: React.ReactNode;
}
@@ -15,11 +15,7 @@ interface State {
error: Error | null;
}
/**
* Catches uncaught render errors so a single broken component doesn't
* black out the whole app. Stack stays visible so users can copy/paste
* it to support; the cloud gets a fire-and-forget operational report.
*/
/** Catches uncaught render errors; fallback shows stack, cloud gets a fire-and-forget report. */
class ErrorBoundary extends React.Component<Props, State> {
state: State = { error: null };
@@ -34,12 +30,9 @@ class ErrorBoundary extends React.Component<Props, State> {
message: String(error?.message || error).slice(0, 500),
stack: String(error?.stack || '').slice(0, 2000),
component_stack: String(info?.componentStack || '').slice(0, 2000),
// Last 10 user-surface actions before the boundary tripped, so the
// backend can correlate the crash with what the user just did.
recent_actions: getRecentActions(10),
});
} catch {}
// surface in dev so developers can read the stack
if (typeof console !== 'undefined' && console.error) {
console.error('[ErrorBoundary]', error, info);
}
@@ -55,7 +48,6 @@ class ErrorBoundary extends React.Component<Props, State> {
};
handleResetState = () => {
// best-effort: clear any localStorage we own + reload
try {
const keys = Object.keys(localStorage);
for (const k of keys) {
@@ -127,7 +119,7 @@ class ErrorBoundary extends React.Component<Props, State> {
<div style={card}>
<h2 style={{ margin: '0 0 8px', fontSize: 18, fontWeight: 600 }}>{title}</h2>
<p style={{ margin: '0 0 16px', color: '#9c9a92', fontSize: 14, lineHeight: 1.5 }}>
We caught it before it crashed everything. The error is below copy it
We caught it before it crashed everything. The error is below; copy it
if you want to share. Reload usually fixes it.
</p>
<div>
+1 -1
View File
@@ -1,6 +1,6 @@
import React from 'react';
/** Cute slime with × eyes and a red error badge error / warning illustration. */
/** Slime illustration with X eyes and red badge for errors/warnings. */
export const ErrorSlime: React.FC<{ size?: number }> = ({ size = 22 }) => (
<svg width={size} height={size} viewBox="0 0 28 28" fill="none" style={{ flexShrink: 0 }}>
<path
@@ -50,7 +50,6 @@ const GlobalSearchPalette: React.FC<Props> = ({ open, onClose }) => {
const searchLoading = useAppSelector((s) => s.agents.historySearch.loading);
const searchQuery = useAppSelector((s) => s.agents.historySearch.query);
// Debounced session/history search.
useEffect(() => {
if (!open) return;
if (debounceRef.current) clearTimeout(debounceRef.current);
@@ -62,7 +61,6 @@ const GlobalSearchPalette: React.FC<Props> = ({ open, onClose }) => {
};
}, [query, open, dispatch]);
// Reset on open + autofocus.
useEffect(() => {
if (open) {
setQuery('');
@@ -75,9 +73,7 @@ const GlobalSearchPalette: React.FC<Props> = ({ open, onClose }) => {
setSelectedIndex(0);
}, [query]);
// Build results: dashboards first, then sessions. Sessions come from
// `historySearch.results` (closed) plus active in-memory sessions
// (not in history yet).
// Dashboards then sessions; merges in-memory active sessions with historySearch.results.
const results = useMemo<Result[]>(() => {
const q = query.trim().toLowerCase();
const dashboardResults: DashboardResult[] = Object.values(dashboards)
@@ -86,9 +82,7 @@ const GlobalSearchPalette: React.FC<Props> = ({ open, onClose }) => {
.slice(0, 5)
.map((d) => ({ kind: 'dashboard', id: d.id, name: d.name }));
// Merge active in-memory sessions with history search results, dedupe by id.
const sessionMap = new Map<string, SessionResult>();
// Active in-memory sessions
for (const s of Object.values(sessions)) {
if (q && !(s.name || '').toLowerCase().includes(q)) continue;
sessionMap.set(s.id, {
@@ -100,8 +94,7 @@ const GlobalSearchPalette: React.FC<Props> = ({ open, onClose }) => {
closedAt: null,
});
}
// When the query is empty, fall back to recent history rather than the
// (potentially huge) history dump — matches what the user sees on init.
// Empty query falls back to recent history, not the full dump.
const historyPool: HistorySession[] = q ? searchResults : Object.values(history).slice(0, 20);
for (const h of historyPool) {
if (sessionMap.has(h.id)) continue;
@@ -123,13 +116,10 @@ const GlobalSearchPalette: React.FC<Props> = ({ open, onClose }) => {
if (r.kind === 'dashboard') {
navigate(`/dashboard/${r.id}`);
} else {
// Session: navigate to its dashboard (if any), focus the card.
// For closed sessions, resume first so the card can render.
if (r.dashboardId) {
navigate(`/dashboard/${r.dashboardId}`);
if (r.closedAt) {
// Closed history session — resume so it lands back in `sessions`
// and the dashboard layout can place a card for it.
// Closed history: resume so it lands in `sessions` and layout can place a card.
dispatch(resumeSession({ sessionId: r.id })).then(() => {
dispatch(setPendingFocusAgentId(r.id));
});
@@ -137,9 +127,7 @@ const GlobalSearchPalette: React.FC<Props> = ({ open, onClose }) => {
dispatch(setPendingFocusAgentId(r.id));
}
} else if (r.closedAt) {
// No dashboard — just resume; the resumed session will land in some
// dashboard if it had one, otherwise it'll be orphan and we can't
// really "navigate" anywhere meaningful.
// Orphan closed session: resume; we can't navigate anywhere meaningful.
dispatch(resumeSession({ sessionId: r.id }));
}
}
@@ -164,11 +152,9 @@ const GlobalSearchPalette: React.FC<Props> = ({ open, onClose }) => {
if (!open) return null;
// Group results visually. Sections collapse if empty.
const dashSection = results.filter((r): r is DashboardResult => r.kind === 'dashboard');
const sessSection = results.filter((r): r is SessionResult => r.kind === 'session');
// Map item index → flat results index for keyboard nav.
const flatIndexOf = (r: Result) => results.indexOf(r);
const isStillSearching = !!query.trim() && searchLoading && searchQuery !== query.trim();
+18 -94
View File
@@ -30,8 +30,7 @@ import SystemUpdateAltIcon from '@mui/icons-material/SystemUpdateAlt';
import CloseIcon from '@mui/icons-material/Close';
import LinearProgress from '@mui/material/LinearProgress';
import CircularProgress from '@mui/material/CircularProgress';
// Settings is a global modal lazy-load so its 2.3K LOC + Stripe / OAuth helpers
// don't ship on first paint. Prefetched on idle so click-to-open feels instant.
// Settings modal lazy-loaded so its 2.3K LOC + Stripe/OAuth helpers don't ship on first paint.
const Settings = React.lazy(() => import('@/app/pages/Settings/Settings'));
import DynamicIsland from '@/app/components/DynamicIsland';
import Dashboard from '@/app/pages/Dashboard/Dashboard';
@@ -67,13 +66,7 @@ const AppShell: React.FC = () => {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const navigateRaw = useNavigate();
// Wrap navigation in startTransition so React treats the route swap
// as non-urgent: the click handler returns immediately and paint
// happens before the heavy unmount-old-page / mount-new-page work
// runs. Eliminates the "click → wait → page appears" gap on slow
// routes (Actions, Apps, Skills) when the main thread is busy with
// agent streaming dispatches. Same call signature as useNavigate's
// return so existing call sites stay untouched.
// startTransition wrapper: route swap becomes non-urgent so click handler returns immediately; eliminates the "click, wait, page appears" gap on slow routes.
const navigate = useMemo(() => {
const fn = (...args: Parameters<typeof navigateRaw>) => {
startTransition(() => {
@@ -111,7 +104,6 @@ const AppShell: React.FC = () => {
});
const [snackbarDismissed, setSnackbarDismissed] = useState(false);
// ---- Warning banner: no internet / no model connected ----
const [isOnline, setIsOnline] = useState(navigator.onLine);
useEffect(() => {
@@ -125,19 +117,11 @@ const AppShell: React.FC = () => {
};
}, []);
// Derive "any model connected" from the /agents/models response (already
// fetched into Redux at app start via Main.tsx and re-fetched by
// Settings.tsx after every subscription connect/disconnect). That endpoint
// intersects BUILTIN_MODELS with both the user's API keys AND 9Router's
// live connection state, so a non-empty byProvider means there's at least
// one usable model — regardless of whether it came from a typed API key
// or an OAuth subscription flow. This replaces the previous approach of
// polling /agents/subscriptions/status in an effect keyed to anthropicKey,
// which didn't refresh when a non-Anthropic subscription was connected.
// /agents/models intersects BUILTIN_MODELS with API keys + 9Router state; non-empty means at least one usable model.
const modelsByProvider = useAppSelector((s) => s.models.byProvider);
const modelsLoaded = useAppSelector((s) => s.models.loaded);
const hasModelConnected = Object.keys(modelsByProvider).length > 0;
// Don't flash the banner while the initial /agents/models fetch is in flight
// Wait for initial fetch to land before flashing the banner.
const showWarningBanner = !isOnline || (modelsLoaded && !hasModelConnected);
const bannerDismissedForVersion = availableVersion != null && dismissedVersion === availableVersion;
@@ -164,14 +148,7 @@ const AppShell: React.FC = () => {
(window as any).openswarm?.installUpdate();
}, [installing, dispatch]);
// Whole-dict subscriptions are deceptively expensive: `state.dashboards.items`
// and `state.outputs.items` are top-level dicts that get a NEW reference
// on any nested mutation (RTK/Immer behavior). With default referential
// equality, AppShell re-rendered on every dashboard rename, every output
// bump, every settings refresh that touched these slices, even though
// the dict CONTENTS were structurally identical from AppShell's POV.
// shallowEqual compares one level deep (key set + each value's identity),
// so AppShell now only re-renders on real structural changes.
// shallowEqual on top-level Immer dicts: nested mutations bump the dict reference, causing AppShell to re-render on every rename/output bump despite identical structure.
const dashboardItems = useAppSelector(
(state) => state.dashboards.items,
shallowEqual,
@@ -199,9 +176,7 @@ const AppShell: React.FC = () => {
dispatch(fetchOutputs());
}, [dispatch]);
// Idle-prefetch the lazy Settings chunk so click-to-open is instant.
// requestIdleCallback waits until the browser is genuinely idle so we
// don't fight first-paint work for the network slot.
// Idle-prefetch the lazy Settings chunk so click-to-open is instant; requestIdleCallback avoids fighting first-paint.
useEffect(() => {
const ric = (window as any).requestIdleCallback || ((cb: () => void) => setTimeout(cb, 1500));
const handle = ric(() => {
@@ -286,9 +261,6 @@ const AppShell: React.FC = () => {
try { localStorage.setItem(SIDEBAR_WIDTH_KEY, String(sidebarWidth)); } catch {}
}, [sidebarWidth]);
// Native notification click handler. The notification helper fires a
// window event with the session id + dashboard id; bring the user back
// to that dashboard and queue a card focus.
useEffect(() => {
const handler = (e: Event) => {
const detail = (e as CustomEvent).detail || {};
@@ -338,8 +310,6 @@ const AppShell: React.FC = () => {
? location.pathname.split('/dashboard/')[1]
: null;
// Sticky last-visited dashboard id — survives navigation away from /dashboard/:id
// so the Dashboard component can stay mounted with stable props.
const [lastDashboardId, setLastDashboardId] = useLastDashboardId();
const activeAppId = location.pathname.startsWith('/apps/')
? location.pathname.split('/apps/')[1]
@@ -397,7 +367,6 @@ const AppShell: React.FC = () => {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100vh', bgcolor: c.bg.page }}>
{/* Draggable title bar */}
<Box
sx={{
height: 38,
@@ -418,10 +387,7 @@ const AppShell: React.FC = () => {
<IconButton
size="small"
onClick={() => setSidebarCollapsed((prev) => !prev)}
// Onboarding handle — the runtime reads aria-expanded to
// detect a collapsed sidebar and walks the user through
// clicking this toggle before targeting any sidebar-* item,
// mirroring the customization-collapse preflight.
// Onboarding runtime reads aria-expanded to detect a collapsed sidebar.
data-onboarding="sidebar-toggle"
aria-expanded={!sidebarCollapsed}
sx={{
@@ -499,7 +465,6 @@ const AppShell: React.FC = () => {
</Box>
</Box>
{/* Warning banner: no internet or no model connected */}
<Collapse in={showWarningBanner} timeout={350} unmountOnExit>
<Box
sx={{
@@ -521,10 +486,10 @@ const AppShell: React.FC = () => {
<ErrorSlime size={22} />
<Typography sx={{ fontSize: '0.78rem', color: '#ef4444', flex: 1, fontWeight: 500, letterSpacing: '0.01em' }}>
{!isOnline
? 'No internet connection agents cannot reach AI models or external services'
? 'No internet connection; agents cannot reach AI models or external services'
: (
<>
No AI model connected {' '}
No AI model connected.{' '}
<Box
component="span"
onClick={() => dispatch(openSettingsModal('models'))}
@@ -653,16 +618,11 @@ const AppShell: React.FC = () => {
}}
>
<Box sx={{ flex: 1, overflow: 'auto', pt: 0.5, '&::-webkit-scrollbar': { width: 0 } }}>
{/* Dashboards section */}
<Box sx={{ px: 1, mb: 0.25 }}>
<ListItemButton
onClick={handleDashboardsClick}
data-onboarding="sidebar-dashboards"
// Expose expanded state so the onboarding runtime can
// skip the sidebar-click step when the section is already
// open (clicking it again would collapse it — opposite
// of what we want). Read via element.dataset.expanded /
// aria-expanded in the runtime guard.
// Onboarding reads expanded so it skips the click step (re-click would collapse).
data-expanded={dashboardsExpanded ? 'true' : 'false'}
aria-expanded={dashboardsExpanded}
sx={{
@@ -736,11 +696,7 @@ const AppShell: React.FC = () => {
return (
<Box
key={entry.id}
// Onboarding targets: every row carries a stable id so
// the AC can point at a specific dashboard, plus the
// first row gets a generic "first" alias so the AC
// can teach "click into a dashboard" without knowing
// any specific id.
// First row gets generic "first" alias so onboarding can teach "click into a dashboard" without a specific id.
data-onboarding={
idx === 0 ? 'dashboard-row-first' : `dashboard-row-${entry.id}`
}
@@ -815,10 +771,8 @@ const AppShell: React.FC = () => {
</Collapse>
</Box>
{/* Divider */}
<Box sx={{ mx: 1.5, my: 0.5, borderTop: `0.5px solid ${c.border.subtle}` }} />
{/* Customization section */}
<Box sx={{ px: 1, mb: 0.25 }}>
<ListItemButton
onClick={() => {
@@ -867,12 +821,7 @@ const AppShell: React.FC = () => {
<Collapse in={customizationExpanded} timeout={200}>
<Box sx={{ ml: 2, mt: 0.25, mb: 0.5, borderLeft: `1px solid ${c.border.medium}` }}>
{CUSTOMIZATION_ITEMS.map((item) => {
// Replaced NavLink with a manual click handler so the
// wrapped (startTransition-aware) navigate runs.
// react-router's NavLink calls its own internal
// navigate which doesn't go through our wrapper,
// bypassing the transition optimization that makes
// Actions/Skills/Modes feel instant.
// Manual click handler instead of NavLink: NavLink's internal navigate bypasses our startTransition wrapper.
const isActive = location.pathname === item.path;
return (
<Box
@@ -880,9 +829,7 @@ const AppShell: React.FC = () => {
data-onboarding={item.onboarding}
onClick={() => navigate(item.path)}
onMouseEnter={() => {
// Hover-prefetch the lazy chunk so the click pays
// ~0ms instead of the multi-hundred-ms chunk parse.
// See Main.tsx for the path → import map.
// Hover-prefetch lazy chunk so click is ~0ms (see Main.tsx for path -> import map).
const fn = (window as any).__openswarmPrefetchRoute;
if (typeof fn === 'function') fn(item.path);
}}
@@ -895,12 +842,7 @@ const AppShell: React.FC = () => {
py: 0.5,
mx: 0.5,
cursor: 'pointer',
// Rounded pill for the active item, same shape as
// toolbar tabs. Use 25-percent accent alpha so
// the warm brand color reads CLEARLY against
// dark-mode bg.secondary; the earlier 10
// percent value muddied to grey and lost the
// selected affordance entirely.
// 25% accent alpha needed for readable contrast on dark-mode bg.secondary; 10% muddied to grey.
borderRadius: `${c.radius.md}px`,
bgcolor: isActive ? `${c.accent.primary}40` : 'transparent',
'&:hover': { bgcolor: isActive ? `${c.accent.primary}55` : `${c.text.tertiary}0A` },
@@ -928,10 +870,8 @@ const AppShell: React.FC = () => {
</Collapse>
</Box>
{/* Divider */}
<Box sx={{ mx: 1.5, my: 0.5, borderTop: `0.5px solid ${c.border.subtle}` }} />
{/* Apps section */}
<Box sx={{ px: 1, mb: 0.25 }}>
<ListItemButton
onClick={handleAppsClick}
@@ -1020,12 +960,6 @@ const AppShell: React.FC = () => {
py: 0.5,
mx: 0.5,
cursor: 'pointer',
// Rounded pill for the active item, same shape as
// toolbar tabs. Use 25-percent accent alpha so
// the warm brand color reads CLEARLY against
// dark-mode bg.secondary; the earlier 10
// percent value muddied to grey and lost the
// selected affordance entirely.
borderRadius: `${c.radius.md}px`,
bgcolor: isActive ? `${c.accent.primary}40` : 'transparent',
'&:hover': { bgcolor: isActive ? `${c.accent.primary}55` : `${c.text.tertiary}0A` },
@@ -1055,7 +989,6 @@ const AppShell: React.FC = () => {
</Box>
{/* Settings */}
<Box
sx={{
px: 1,
@@ -1108,11 +1041,7 @@ const AppShell: React.FC = () => {
onMouseDown={handleResizeStart}
onDoubleClick={handleResizeDoubleClick}
sx={{
// Hit-target is 6px for ergonomic drag but the handle is
// positioned at -3px so it overlaps the sidebar/content seam
// instead of occupying its own visible column. This kills the
// "chunky empty strip" that read as bad spacing without
// shrinking the actual drag region.
// 6px hit-target at -3px margin overlaps the seam so the drag region doesn't read as a visible empty strip.
width: 6,
marginLeft: '-3px',
marginRight: '-3px',
@@ -1143,8 +1072,7 @@ const AppShell: React.FC = () => {
)}
<Box sx={{ flex: 1, overflow: 'hidden', bgcolor: c.bg.page, position: 'relative' }}>
{/* Non-dashboard routes render here. Hidden when the dashboard view is active
so the persistent Dashboard layered above can take over the visible area. */}
{/* Hidden (not unmounted) when the dashboard view is active so the persistent Dashboard layered above can take over. */}
<Box
sx={{
position: 'absolute',
@@ -1156,11 +1084,7 @@ const AppShell: React.FC = () => {
<Outlet />
</Box>
{/* Persistent Dashboard layer — always mounted once a dashboard has been visited.
Hidden via CSS when on other routes so webviews and dashboard state survive
route navigation. The Dashboard component reads its dashboardId from the
sticky lastDashboardId hook so its dashboardId useEffect doesn't re-fire on
incidental URL changes. */}
{/* CSS-hidden on other routes so webviews + state survive nav. */}
{lastDashboardId && (
<DashboardHost visible={isDashboardViewActive}>
<Dashboard dashboardId={lastDashboardId} isActive={isDashboardViewActive} />
@@ -1242,7 +1166,7 @@ const AppShell: React.FC = () => {
}}
>
{updateStatus === 'available' && `OpenSwarm ${availableVersion} is available`}
{updateStatus === 'downloaded' && `OpenSwarm ${availableVersion} downloaded restart to update`}
{updateStatus === 'downloaded' && `OpenSwarm ${availableVersion} downloaded; restart to update`}
</Alert>
</Snackbar>
</Box>
@@ -6,24 +6,9 @@ interface DashboardHostProps {
children: React.ReactNode;
}
/**
* Wraps the Dashboard component in a stable container that toggles visibility
* via CSS instead of unmounting. This is what keeps the embedded webviews
* alive across non-dashboard route navigation.
*
* Why this approach (vs. display: none or unmount):
* - `visibility: hidden` preserves webview state without triggering Chromium
* to mark the page as hidden (so background sub-agents keep working).
* - `display: none` would trigger full layout recalc on toggle and may pause
* pages that check `document.hidden`.
* - Unmount destroys the webview DOM element, tearing down its Chromium tab.
*
* Also provides DashboardActiveContext to all children so they can gate
* expensive work (canvas rendering, screenshot capture, etc.) on visibility.
*/
/** Stable container that hides Dashboard via CSS so embedded webviews survive non-dashboard nav. */
const DashboardHost: React.FC<DashboardHostProps> = ({ visible, children }) => {
// When transitioning from visible -> hidden, blur any focused element so
// a focused webview doesn't keep stealing keyboard input behind the scenes.
// Blur focused element on hide so a focused webview can't keep stealing keyboard input.
useEffect(() => {
if (!visible) {
const el = document.activeElement;
@@ -38,10 +23,8 @@ const DashboardHost: React.FC<DashboardHostProps> = ({ visible, children }) => {
style={{
position: 'absolute',
inset: 0,
// Negative z-index when hidden so any visible Outlet content sits above
zIndex: visible ? 10 : -1,
visibility: visible ? 'visible' : 'hidden',
// Belt-and-suspenders: even if z-index ordering glitches, no clicks land
pointerEvents: visible ? 'auto' : 'none',
}}
>
+2 -16
View File
@@ -6,21 +6,7 @@ import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { DURATION_MS, EASE, pulseKeyframes } from '@/shared/styles/motionTokens';
import { useReducedMotion } from '@/shared/hooks/useReducedMotion';
/**
* Unified loading primitives. Three components, one aesthetic.
*
* <Skeleton variant="card|line|circle" width height />
* For full-component / full-page loads. Replaces decorative spinners.
*
* <InlineSpinner size />
* For inline button states + OAuth waits. Spinner = "I'm doing it now".
*
* <EmptyState icon title hint />
* For "nothing here yet" empty lists. Replaces ad-hoc "Loading..." text.
*
* `delayMs` (Skeleton + EmptyState): don't show until N ms have elapsed.
* Prevents the flash-of-skeleton on fast loads (<100ms common case).
*/
/** Loading primitives: Skeleton (block load), InlineSpinner (inline waits), EmptyState (no-items). */
interface SkeletonProps {
variant?: 'card' | 'line' | 'circle' | 'custom';
@@ -88,7 +74,7 @@ interface EmptyStateProps {
icon?: React.ReactNode;
title: string;
hint?: string;
/** Show after N ms keeps "Loading..." flash off fast paths */
/** Show after N ms; keeps "Loading..." flash off fast paths. */
delayMs?: number;
}
@@ -1,15 +1,4 @@
// Singleton glue between the Onboarding panel UI and the AC runtime.
//
// Lifecycle:
// - OnboardingRoot mounts, calls Director.attach({ acRef, store, getAccentColor })
// - Panel "Show me" click → Director.startStep(stepId, sourceRect)
// - Director creates an AbortController, hands off to acRuntime.runStep
// - User dismisses panel mid-step → Director.cancelStep() → controller.abort()
//
// The runtime is the only place that touches the cursor handle directly.
// The Director is just a thin policy layer — it picks the spawn point,
// resolves dependencies, and translates Redux state into "should we walk
// step 4 again before step 5."
// Glue between the Onboarding panel and the AC runtime; thin policy layer over acRuntime.runStep.
import type { Store } from '@reduxjs/toolkit';
import type { RootState } from '@/shared/state/store';
@@ -24,9 +13,7 @@ interface AttachArgs {
acRef: RefObject<AgenticCursorHandle | null>;
store: Store<RootState>;
getAccentColor: () => string;
// Resolves whether a dependency's outcome is still satisfied. If true,
// the dependency's flow is skipped during walk_again. Step-5's depCheck,
// for example, asks "is there still a live browser card on the canvas?"
/** True if a dep is still satisfied; if so walk_again skips its flow. */
isDependencySatisfied: (depId: string) => boolean;
}
@@ -84,26 +71,9 @@ class OnboardingDirector {
const controller = new AbortController();
this.currentAbort = controller;
// Adaptive abort hooks — fire controller.abort() so the runtime's
// existing cleanup path takes over (cursor outros, popup retreats,
// panel re-shows for the user to re-attempt).
//
// 1. Lost target — tracker fires this when its cached element has
// been disconnected for >2.5s (user navigated away, collapsed
// the section, swapped a card out from under us).
// 2. Hash-route change — user clicked a sidebar entry / dashboard
// item / settings link mid-flow. Capture the route at start time
// and abort if it changes; lets the user explore freely without
// the AC stranding itself on the wrong page.
// Abort hooks: lost-target (cached element disconnected >2.5s) and hash-route change.
const startHash = window.location.hash;
// Console-visible breadcrumb for which abort listener fired. The
// existing `report()` calls only go to analytics; we couldn't tell
// whether step 8's recurring `AbortError: aborted` was from a
// lost-target (chat-input element disconnected by an in-flight
// remount) or from a route change (`hashchange` firing as a side
// effect of e.g. ViewEditor calling history.replaceState mid-flow).
// Logging on each abort path resolves that ambiguity without
// needing to open the Network/Analytics panel.
// Console breadcrumbs distinguish lost-target vs hashchange aborts without the Analytics panel.
const onLost = (e: Event) => {
const detail = (e as CustomEvent)?.detail;
// eslint-disable-next-line no-console
@@ -151,16 +121,11 @@ class OnboardingDirector {
}
}
// 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();
// Convenience: return the ordered roadmap (1..10) so callers don't import STEPS
// directly when they just need the schedule. STEPS itself is the source of truth.
/** Ordered roadmap (1..10); STEPS is the source of truth. */
export function getRoadmap(): OnboardingStep[] {
return STEPS;
}
@@ -1,13 +1,4 @@
// Docked top-right panel. Three visible states:
// - 'pill' — small "Finish setup X/N · Continue →" pill
// - 'expanded' — full card with title/desc/video preview/Show me + See all todos
// - 'roadmap' — full 10-step modal (delegated to OnboardingRoadmapModal)
// - 'hidden' — user-dismissed; only re-shows via Settings → Restart tour
//
// When a step completes, we render a one-time celebration overlay (check
// icon + strike-through over the title) for ~1500ms before crossfading to
// the next step's card. justCompletedStepId in Redux drives this; the
// useEffect below clears it on a timer.
/** Docked top-right panel; states: pill, expanded, roadmap, hidden. */
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
@@ -30,13 +21,9 @@ import { cursorStore } from './ac/cursorStore';
import OnboardingRoadmapModal from './OnboardingRoadmapModal';
const PANEL_WIDTH = 420;
// Long enough to register the strike-through + check, short enough that
// it doesn't feel like waiting before the next step appears.
const CELEBRATION_MS = 900;
// Tiny cursor-arrow SVG that mirrors the shape rendered by AgenticCursor
// so the AC visually appears to "come to life" out of this icon when the
// user clicks Show me.
/** Mirrors AgenticCursor's shape so AC visually "comes to life" out of this icon on Show me click. */
const CursorIconSmall: React.FC<{ size?: number; color: string }> = ({
size = 14,
color,
@@ -67,16 +54,11 @@ const OnboardingPanel: React.FC = () => {
const infoBtnRef = useRef<HTMLButtonElement | null>(null);
const [infoOpen, setInfoOpen] = useState(false);
// Cursor icon inside the "Show me" button — used to calculate the AC
// spawn point so the cursor visually flies out of this exact icon.
// AC spawn point flies out of this icon.
const cursorIconRef = useRef<HTMLSpanElement | null>(null);
// Cooldown for the Show me button so rapid double-clicks don't fire
// multiple parallel step starts (each one re-triggering backend
// seed/launch calls that already have an in-flight predecessor).
// Cooldown so rapid double-clicks don't fire parallel step starts; each one re-triggers in-flight backend seed/launch calls.
const lastShowMeClickRef = useRef<number>(0);
// Resolve current step. Prefer explicit currentStepId; fall back to
// first uncompleted step.
const currentStep = useMemo(() => {
const explicit = progress.currentStepId
? findStepById(progress.currentStepId)
@@ -85,8 +67,7 @@ const OnboardingPanel: React.FC = () => {
return STEPS.find((s) => !progress.completedSteps.includes(s.id)) ?? null;
}, [progress.currentStepId, progress.completedSteps]);
// Stage-relative progress counts. Spec mockup shows "Get started 1/6"
// (per-stage), not "1/10" (overall). The pill keeps overall.
// Stage-relative (panel) vs overall (pill).
const stageOf = currentStep?.stage ?? 'get_started';
const stageSteps = useMemo(
() => STEPS.filter((s) => s.stage === stageOf),
@@ -99,60 +80,33 @@ const OnboardingPanel: React.FC = () => {
const total = STEPS.length;
const done = progress.completedSteps.length;
// Celebration banner — strike-through + check on the just-completed
// step. Timer lives INSIDE CelebrationView so it can't be cancelled
// by parent OnboardingPanel re-renders or AnimatePresence remounts.
// Removed the parent-level useEffect that was here; it was vulnerable
// to a "rapid re-render → cleanup → new timer → repeat" loop where
// the celebration would never actually clear.
// Timer lives inside CelebrationView so parent re-renders can't cancel it.
const justDoneStepId = progress.justCompletedStepId;
const justDoneStep = justDoneStepId ? findStepById(justDoneStepId) : null;
const handleShowMe = async () => {
if (!currentStep) return;
// Click cooldown — without this, rapid double-clicks fire startStep
// twice. Each invocation calls cancelStep() then starts fresh, but
// any in-flight async ops (seed-orchestration-demo, agent launch,
// etc) keep running because cancelStep only aborts the controller,
// not pending backend fetches. Result: multiple stub agents
// created, multiple agents launched, panel state thrashing. 600ms
// is short enough not to feel laggy, long enough to absorb the
// user's "is it broken" reflex re-click.
// 600ms cooldown: cancelStep doesn't kill in-flight backend fetches so spam would launch parallel sessions.
const now = Date.now();
if (now - lastShowMeClickRef.current < 600) return;
lastShowMeClickRef.current = now;
// If running flag is stuck at true (a prior step's runStep ended
// without resetting it — possible after an unhandled error or HMR
// cycle), forcibly cancel and reset before starting fresh. This
// unsticks the "Show me does nothing" case without forcing the
// user to reload the app.
// Unstick a stale "running" flag from a prior unhandled error or HMR; yield a tick so reset lands first.
if (progress.running) {
onboardingDirector.cancelStep();
progress.setRunning(false);
// Yield a tick so the running=false dispatch lands before we
// start the new step (otherwise the runtime's first dispatch
// races with the reset).
await new Promise<void>((r) => window.setTimeout(r, 0));
}
const iconEl = cursorIconRef.current;
const rect = iconEl?.getBoundingClientRect();
// Sanity-check the rect: if the panel is mid-transition (Framer's
// exit animation hasn't completed), getBoundingClientRect can return
// (0,0,0,0) — which would land the cursor at the top-left corner
// (over the macOS traffic lights). Fall back to a sensible
// top-right anchor when the rect looks degenerate.
// Mid-transition rects can be 0,0,0,0; fall back to a top-right anchor.
const validRect =
rect && (rect.width > 0 || rect.height > 0) && (rect.left > 0 || rect.top > 0);
const spawnPoint = validRect
? { x: rect!.left + rect!.width / 2, y: rect!.top + rect!.height / 2 }
: { x: window.innerWidth - 80, y: 110 };
report('show_me_clicked', { step_id: currentStep.id });
// Watchdog: if AC fails to become visible within 2s of Show me
// (acRef.current was null after an HMR cycle, fadeIn silently
// rejected, etc), the panel stays hidden because nothing resets
// `running`. Check the cursorStore — if visible is still false,
// recover so the panel comes back instead of stranding the user.
// 2s watchdog recovers the panel if AC never becomes visible (HMR / silent rejection).
const watchedStepId = currentStep.id;
window.setTimeout(() => {
const acVisible = cursorStore.get().visible;
@@ -168,12 +122,7 @@ const OnboardingPanel: React.FC = () => {
if (!currentStep && !justDoneStep) return null;
if (progress.panelMode === 'hidden') return null;
// While AC is actively walking the user through a step, the panel
// would otherwise sit on top of targets in the top-right corner
// (Skills install button, "+ New app" on the Apps page, the Apps
// toolbar button, etc). Slide it off-screen with a small fade so the
// cursor has a clean canvas; it animates back when the step outros.
// motion.div handles both directions of the transition.
// Slide panel off-screen while AC runs so it doesn't sit on top of top-right targets (Skills install, "+ New app", etc).
const panelHidden = progress.running;
return (
@@ -187,11 +136,7 @@ const OnboardingPanel: React.FC = () => {
transition={{ type: 'spring', stiffness: 280, damping: 32 }}
sx={{
position: 'fixed',
// 38px title bar (drag region with traffic lights / OpenSwarm logo)
// + 6px breathing room. Sits just below the title bar — clear of
// the logo in the right corner but tighter to it than the
// previous 54px so the pill doesn't visually float away from
// the chrome.
// 38px title bar + 6px breathing room.
top: 44,
right: 16,
zIndex: 1200,
@@ -277,10 +222,6 @@ const OnboardingPanel: React.FC = () => {
overflow: 'hidden',
}}
>
{/* Header — stage label + minimize + progress bar. No
bottom border anymore: the progress bar IS the
visual divider between header and body, no need for
a second separator line below it. */}
<Box
sx={{
px: 1.6,
@@ -347,8 +288,6 @@ const OnboardingPanel: React.FC = () => {
</Box>
</Box>
{/* Body — celebration overlay or current step. AnimatePresence
crossfades between them so step transitions feel smooth. */}
<Box sx={{ position: 'relative' }}>
<AnimatePresence mode="wait" initial={false}>
{justDoneStep ? (
@@ -407,9 +346,7 @@ const OnboardingPanel: React.FC = () => {
</AnimatePresence>
</Box>
{/* Floating "?" info popover, anchored to the info icon. Renders
OUTSIDE the panel container so it can extend to the left without
clipping. */}
{/* Rendered outside the panel container so it can extend left without clipping. */}
{infoOpen && currentStep && (
<InfoPopover
stepId={currentStep.id}
@@ -445,10 +382,7 @@ const StepCardBody: React.FC<StepCardProps> = ({
onToggleInfo,
running,
}) => {
// Click-to-zoom on the demo video. Lives at the card level so the
// overlay is portaled out (full viewport) regardless of how the panel
// is positioned. Auto-collapses on step change so a leftover overlay
// from step N doesn't linger into step N+1.
// Auto-collapses on step change so a leftover overlay from step N doesn't linger into step N+1.
const [videoExpanded, setVideoExpanded] = useState(false);
useEffect(() => {
setVideoExpanded(false);
@@ -516,10 +450,7 @@ const StepCardBody: React.FC<StepCardProps> = ({
width: '100%',
height: '100%',
objectFit: 'cover',
// The source recordings have baked-in black side bars
// (recorded at a wider canvas than the OpenSwarm window
// actually filled). Scaling up + overflow:hidden on the
// parent crops them off the visible thumbnail area.
// Source recordings have baked-in black side bars; scale + parent overflow:hidden crops them off.
transform: 'scale(1.0)',
transformOrigin: 'center',
pointerEvents: 'none',
@@ -572,9 +503,6 @@ const StepCardBody: React.FC<StepCardProps> = ({
<ButtonBase
onClick={onOpenRoadmap}
sx={{
// mlAuto: pushes the help icon (next sibling) to the far
// right while keeping "See all todos" tucked next to Show
// me. Visual rhythm: [Show me] See all todos ............ ?
fontSize: 12.5,
fontWeight: 500,
color: c.text.secondary,
@@ -599,11 +527,6 @@ const StepCardBody: React.FC<StepCardProps> = ({
</IconButton>
</Box>
</Box>
{/* Click-zoom overlay — portaled to body so it covers the full
viewport regardless of how the panel is positioned. Lives as a
sibling of the main card Box rather than as a child so the card
Box's children list stays a clean array of static elements
(helps React's children-validation in dev). */}
{videoExpanded && step.videoSrc
? createPortal(
<Box
@@ -681,18 +604,12 @@ interface CelebrationProps {
const CelebrationView: React.FC<CelebrationProps> = ({ step, accent }) => {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
// Self-clearing timer: lives with the component instance and
// dispatches clearJustCompleted on mount. Because this component
// ONLY mounts when justCompletedStepId is set and unmounts when
// it's cleared, the timer fires exactly once per celebration.
// Cannot be cancelled by parent re-renders.
// Self-clearing timer fires once per celebration; cannot be cancelled by parent re-renders.
useEffect(() => {
const t = window.setTimeout(() => {
dispatch(clearJustCompleted());
}, CELEBRATION_MS);
return () => window.clearTimeout(t);
// Empty deps = fires once on mount, cleans up on unmount. The
// dispatch ref is stable per redux store.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
@@ -804,8 +721,6 @@ const InfoPopover: React.FC<InfoPopoverProps> = ({ stepId, anchorRef, onClose, t
if (!r) return;
const POPOVER_W = 280;
const POPOVER_H = 240;
// Anchor below-and-to-the-left of the info button so the popover
// sits to the LEFT of the panel — matches figma image #66.
const top = Math.min(r.bottom + 8, window.innerHeight - POPOVER_H - 8);
const left = Math.max(8, r.right - POPOVER_W);
setPos({ top, left });
@@ -815,12 +730,10 @@ const InfoPopover: React.FC<InfoPopoverProps> = ({ stepId, anchorRef, onClose, t
return () => window.removeEventListener('resize', calc);
}, [anchorRef]);
// Click-away listener.
useEffect(() => {
const handler = (e: MouseEvent) => {
const t = e.target as Node;
if (anchorRef.current?.contains(t)) return;
// If click landed inside the popover, leave it open.
const pop = document.getElementById('onboarding-info-popover');
if (pop?.contains(t)) return;
onClose();
@@ -1,7 +1,4 @@
// Redux slice mirroring the persisted onboarding-v2 state. A thin
// subscriber in OnboardingRoot writes back to localStorage on change
// (debounced 200ms) so the in-memory state is the source of truth at
// runtime and disk is just for resume-after-restart.
// Mirrors persisted onboarding-v2 state; OnboardingRoot debounce-writes to localStorage on change.
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
@@ -13,8 +10,7 @@ export type PanelMode = 'pill' | 'expanded' | 'roadmap' | 'hidden';
export interface PerStepState {
lastViewedAt: number;
videoWatched?: boolean;
// For multi-choice steps: which option the user picked (used for branching
// and analytics).
/** Multi-choice answers per opId; drives branching and analytics. */
multiChoiceAnswers?: Record<string, string>;
}
@@ -26,25 +22,13 @@ export interface OnboardingProgressState {
panelMode: PanelMode;
dismissedAt: number | null;
perStepState: Record<string, PerStepState>;
// Runtime-only — not persisted. True while AC is actively executing a
// step's ops. The panel hides chrome and the user can't open the roadmap
// mid-flow without first cancelling.
/** Runtime-only; true while AC is executing a step's ops. */
running: boolean;
// Set on first launch detection so we don't re-init from defaults on
// every mount.
/** Set on first-launch detection so we don't re-init defaults on every mount. */
initialized: boolean;
// Set briefly when a step completes so the panel can render a one-time
// strike-through + celebration animation before transitioning to the
// next step. Cleared by clearJustCompleted (the panel calls this from
// a 1500ms timeout after the animation plays).
/** Brief celebration marker; clearJustCompleted clears it ~1.5s after the animation. */
justCompletedStepId: string | null;
// True after the user explicitly restarts the tour from Settings.
// Suppresses skipIf-based auto-marking for the rest of this tour run
// so the user gets a true fresh experience even if their prior data
// (existing skills, sessions, configured tools) would otherwise
// satisfy the predicates. False during normal first-launch detection
// so legitimately upgrading v1.0.29 users still see their already-
// configured pieces correctly pre-marked.
/** True after explicit restart-from-Settings; suppresses skipIf so the tour feels fresh. */
disableSkipIf: boolean;
}
@@ -86,9 +70,7 @@ const initialState: OnboardingProgressState = {
startedAt: 0,
completedSteps: [],
currentStepId: null,
// Default to expanded users land on the dashboard with the full
// step card visible so they see the next milestone + video preview
// without having to click into the pill first.
// Default expanded so users see next milestone + video preview on dashboard land.
panelMode: 'expanded',
dismissedAt: null,
perStepState: {},
@@ -123,7 +105,6 @@ const slice = createSlice({
state.disableSkipIf = Boolean(action.payload.disableSkipIf);
},
hydrate(state, action: PayloadAction<OnboardingProgressState>) {
// Replace from localStorage on launch.
Object.assign(state, action.payload, { running: false, initialized: true });
},
setPanelMode(state, action: PayloadAction<PanelMode>) {
@@ -145,8 +126,7 @@ const slice = createSlice({
markStepCompleted(state, action: PayloadAction<string>) {
if (!state.completedSteps.includes(action.payload)) {
state.completedSteps.push(action.payload);
// Trigger the celebration / strike-through animation. The panel
// listens for this and clears it ~1.5s later via clearJustCompleted.
// Triggers celebration anim; panel clears via clearJustCompleted after ~1.5s.
state.justCompletedStepId = action.payload;
}
},
@@ -176,11 +156,7 @@ const slice = createSlice({
state.perStepState = {};
state.running = false;
state.startedAt = Date.now();
// Tour was explicitly restarted — give the user a true fresh
// experience by suppressing skipIf for the rest of this run.
// Otherwise residual data (existing skills installed during a
// prior tour, leftover seed-orchestration-demo agents, etc)
// would auto-mark steps complete the moment Redux state ticks.
// Explicit restart: suppress skipIf so residual prior-tour data can't auto-mark.
state.disableSkipIf = true;
},
},
@@ -1,5 +1,4 @@
// Full 10-step roadmap. Modal opens from the panel's "See all todos" link.
// Stages cascade: Stage 2 unlocks once Stage 1 is fully complete.
/** 10-step roadmap modal opened from the panel's "See all todos"; Stage 2 unlocks once Stage 1 is fully complete. */
import React from 'react';
import { Modal, Box, Typography, IconButton, Button } from '@mui/material';
@@ -37,28 +36,18 @@ const OnboardingRoadmapModal: React.FC = () => {
progress.setPanelMode('expanded');
};
// Anchor the roadmap to the same top-right corner the panel sits in,
// so visually it reads as the panel "expanding into" the full roadmap
// rather than a centered modal that breaks spatial continuity. The
// origin point matches OnboardingPanel's top:44 / right:16 dock.
// Anchored top:44 / right:16 to match OnboardingPanel's dock so the modal reads as the panel expanding.
return (
<Modal
open={open}
onClose={close}
// Disable Modal's internal flex centering — we position the inner
// box absolutely from the top-right corner ourselves.
sx={{ inset: 0 }}
slotProps={{
backdrop: {
sx: { backgroundColor: 'rgba(0,0,0,0.42)' },
},
}}
// Modal mounts as soon as `open` is true; AnimatePresence inside
// owns the actual exit animation, so we keep keepMounted off and
// use AnimatePresence with mode="wait".
>
{/* Outer Box gets focus / aria attributes from MUI Modal. The
motion.div inside handles the slide-in. */}
<Box
sx={{
position: 'absolute',
@@ -79,8 +68,6 @@ const OnboardingRoadmapModal: React.FC = () => {
>
<Box
sx={{
// Roughly the same width as the expanded panel, just a touch
// wider so the 8-row roadmap breathes. 360 vs panel's 320.
width: 360,
maxHeight: 'calc(100vh - 80px)',
overflowY: 'auto',
@@ -93,7 +80,6 @@ const OnboardingRoadmapModal: React.FC = () => {
fontFamily: c.font.sans,
}}
>
{/* Header */}
<Box
sx={{
display: 'flex',
@@ -129,7 +115,6 @@ const OnboardingRoadmapModal: React.FC = () => {
</IconButton>
</Box>
{/* Stages */}
<Box sx={{ px: 2.4, pt: 1.6, pb: 0.5 }}>
{STAGE_GROUPS.map((group, gi) => {
const stageDone = group.steps.filter((s) =>
@@ -191,9 +176,7 @@ const OnboardingRoadmapModal: React.FC = () => {
key={step.id}
onClick={() => {
if (isLocked) return;
// If a step is mid-flow, abort it before
// jumping. Otherwise the AC keeps animating
// for a step the user no longer sees.
// Abort mid-flow step before jumping; otherwise AC keeps animating for a step the user no longer sees.
if (progress.running) {
onboardingDirector.cancelStep();
}
@@ -270,7 +253,6 @@ const OnboardingRoadmapModal: React.FC = () => {
})}
</Box>
{/* Footer */}
<Box
sx={{
px: 2.4,
@@ -1,5 +1,4 @@
// Top-level mount for the onboarding-v2 system. Hydrates persisted state,
// attaches the Director, mounts the Panel + AC.
// Top-level mount for onboarding-v2: hydrate state, attach Director, mount Panel + AC.
import React, { useEffect, useRef } from 'react';
import { useStore } from 'react-redux';
@@ -32,7 +31,6 @@ const OnboardingRoot: React.FC = () => {
const userId = useAppSelector((s) => s.settings.data.user_id ?? null);
const settingsLoaded = useAppSelector((s) => s.settings.loaded);
// Hydrate from localStorage on first mount, or initialize fresh state.
useEffect(() => {
if (progress.initialized) return;
if (!settingsLoaded) return;
@@ -43,15 +41,7 @@ const OnboardingRoot: React.FC = () => {
return;
}
// Always start with no pre-completed steps. The legitimate "v1.0.29
// user has a model already configured" case is now handled by the
// user simply walking through step 1 — the skipIf predicates still
// exist but they fire only via the live subscriber's baseline-aware
// path, which gates them behind real user action. Pre-marking at
// init time was unreliable: backend fetches land async, and at
// mount time we either don't have data yet (so nothing to mark)
// or we have it via stale Redux from a previous run (so we
// wrongly mark the wrong things). Net: simpler + always-fresh.
// Start with no pre-completed steps; live subscriber handles skipIf after baseline capture.
dispatch(
init({
currentStepId: STEPS[0]?.id ?? null,
@@ -61,19 +51,7 @@ const OnboardingRoot: React.FC = () => {
);
}, [progress.initialized, settingsLoaded, dispatch, store]);
// Watch for "user did the onboarding thing outside the flow" + bridge
// selected Redux signals to the event bus.
//
// Critical perf detail: the naive store.subscribe runs on EVERY dispatch
// (chat streaming = hundreds per second). The inner work — looping all
// STEPS, walking sessions, walking browserCards — is small individually
// but death-by-a-thousand-cuts over a long agent stream.
//
// Mitigation: collapse all dispatches in the same microtask into a
// single check via a `pending` flag + queueMicrotask. The state we
// care about (skipIf evaluations, card counts, session statuses) only
// matters at *commit* boundaries, never per-action — so coalescing
// dispatches is free.
// Bridge Redux signals to bus + auto-mark on skipIf. Coalesces microtask-bursts of dispatches.
useEffect(() => {
let last = new Set(progress.completedSteps);
let lastBrowserCount = Object.keys(
@@ -86,20 +64,7 @@ const OnboardingRoot: React.FC = () => {
(store.getState() as any).outputs?.items ?? {},
).length;
// Baseline-snapshot of which skipIf predicates were ALREADY satisfied
// at startup. Any step whose predicate is in this set won't be
// auto-marked by the live subscriber — the user has to actually go
// through it (or do the equivalent thing during this run). This kills
// the "step 3 instantly marks done because backend fetchSessions
// landed" bug, where async data arriving post-mount caused predicates
// to flip false→true and the subscriber marked steps without any
// user interaction.
//
// The snapshot is captured on the first store-tick AFTER a small
// settle delay — enough for fetchSettings/Sessions/Skills/Outputs
// to all land. Anything true at that point counts as "pre-existing
// backend state" and is excluded from auto-marking for the rest
// of the run.
// Snapshot pre-satisfied skipIf predicates after a 2s settle; those steps need real user action to mark.
let baselinePredicateMet: Set<string> | null = null;
const baselineCaptureAt = Date.now() + 2000;
let lastStatuses: Record<string, string> = {};
@@ -115,11 +80,7 @@ const OnboardingRoot: React.FC = () => {
seedStatuses();
let pending = false;
// Cached slice references — if these are referentially equal to what
// we saw last microtask, NOTHING we care about could have changed.
// Redux Toolkit's Immer produces new references only on slice writes,
// so identity comparison is sound and ~free. Drops the steady-state
// cost of this subscriber to a 5-pointer comparison per microtask.
// Slice-ref identity check; Immer mutates only on write so this 5-pointer compare is sound and free.
let prevAgents: unknown = null;
let prevDashboardLayout: unknown = null;
let prevOutputs: unknown = null;
@@ -129,11 +90,7 @@ const OnboardingRoot: React.FC = () => {
const runCheck = () => {
pending = false;
const state = store.getState();
// Reference-equality early-out. If none of the slices that drive
// any predicate, count, or status walk have changed reference,
// there's no work to do. Streaming chunks, agent message updates,
// settings polls all dispatch but most of them touch a single
// unrelated slice — so this skips ~95% of microtask wakeups.
// Early-out if no relevant slice reference moved; skips ~95% of microtask wakeups.
const sAgents = (state as any).agents;
const sLayout = state.dashboardLayout;
const sOutputs = (state as any).outputs;
@@ -153,8 +110,7 @@ const OnboardingRoot: React.FC = () => {
if (!anyChanged) return;
const suppressSkipIf = state.onboardingProgress?.disableSkipIf === true;
// Capture the baseline of pre-satisfied predicates after the
// initial fetch settle. This snapshot is sticky for the run.
// Capture pre-satisfied predicates after the fetch settle; sticky for the run.
if (baselinePredicateMet === null && Date.now() >= baselineCaptureAt) {
baselinePredicateMet = new Set();
for (const s of STEPS) {
@@ -165,11 +121,7 @@ const OnboardingRoot: React.FC = () => {
const allSkippablesDone = STEPS.every(
(s) => !s.skipIf || last.has(s.id),
);
// Skip the live evaluation entirely if (a) suppression is on,
// (b) baseline hasn't captured yet (we're still in the settle
// window — predicates would just see fetch-driven false→true
// flips that we want to ignore), or (c) every skippable step
// is already marked.
// Skip evaluation if suppressed, pre-baseline, or every skippable is already marked.
if (
!suppressSkipIf &&
!allSkippablesDone &&
@@ -178,11 +130,7 @@ const OnboardingRoot: React.FC = () => {
for (const s of STEPS) {
if (last.has(s.id)) continue;
if (!s.skipIf) continue;
// Predicates that were ALREADY true at baseline are excluded —
// the only way to mark them complete now is via genuine user
// action (bus events fired from product code) or via the
// tour's outro path. Prevents fetched-from-backend data from
// leaking past the gate later in the run.
// Baseline-met predicates require real user action (bus events or outro) to mark.
if (baselinePredicateMet.has(s.id)) continue;
if (s.skipIf(state)) {
last = new Set([...Array.from(last), s.id]);
@@ -228,18 +176,14 @@ const OnboardingRoot: React.FC = () => {
};
return store.subscribe(() => {
// Coalesce N dispatches in the same microtask into 1 check. Cheap
// boolean flag + queueMicrotask means the cost per dispatch is now
// a single property write, not a full state walk. The actual work
// still runs at most once per "tick" of state updates — which is
// all that matters for skipIf semantics.
// Coalesce N dispatches in the same microtask into 1 check.
if (pending) return;
pending = true;
queueMicrotask(runCheck);
});
}, [progress.completedSteps, dispatch, store]);
// Persist Redux progress localStorage, debounced.
// Persist Redux progress to localStorage, debounced.
useEffect(() => {
if (!progress.initialized) return;
const t = window.setTimeout(() => {
@@ -248,14 +192,13 @@ const OnboardingRoot: React.FC = () => {
return () => window.clearTimeout(t);
}, [progress, store]);
// Attach Director once the AC is mounted.
useEffect(() => {
onboardingDirector.attach({
acRef,
store,
getAccentColor: () => tokens.accent.primary,
isDependencySatisfied: (depId) => {
// Step 4's outcome is "a browser card currently exists on the canvas."
// Step 4: browser card currently on canvas.
if (depId === 'use_browser') {
const cards = store.getState().dashboardLayout?.browserCards ?? {};
return Object.keys(cards).length > 0;
@@ -266,9 +209,7 @@ const OnboardingRoot: React.FC = () => {
return () => onboardingDirector.detach();
}, [store, tokens.accent.primary]);
// Don't render the panel until we know whether the user is signed in. The
// panel sits on the dashboard, which only mounts post-sign-in anyway, but
// this guard keeps us out of the SignInGate's z-index space.
// Wait for sign-in state so we don't render under the SignInGate's z-index.
if (!settingsLoaded || !userId) return null;
if (!progress.initialized) return null;
@@ -1,6 +1,4 @@
// Visual gesture helpers — drop a transient DOM node, animate it, clean up.
// These don't trigger any product code; they just render eye-candy that
// makes the cursor's "intent" legible (a click ripple, a drag-rect).
// Transient visual gesture helpers: click ripple, drag-rect, glow.
export function clickRipple(x: number, y: number, color: string): void {
const SIZE = 28;
@@ -46,7 +44,7 @@ export function animateDragSelect(rect: DragRect, color: string, durationMs = 60
'width: 0px',
'height: 0px',
`border: 1.5px dashed ${color}`,
`background: ${color}1a`, // ~10% alpha
`background: ${color}1a`,
'pointer-events: none',
'z-index: 10499',
'border-radius: 4px',
@@ -69,9 +67,7 @@ export function animateDragSelect(rect: DragRect, color: string, durationMs = 60
});
}
// Soft glow rect overlaid on a target element. Used by highlight_section to
// draw the user's eye to a region (e.g. settings-pro-section) without
// taking a click. Caller is responsible for calling the returned cleanup.
/** Soft glow rect over a target (no click); caller must invoke the returned cleanup. */
export function spawnGlowRect(target: HTMLElement, color: string): () => void {
const rect = target.getBoundingClientRect();
const pad = 6;
@@ -100,7 +96,7 @@ export function spawnGlowRect(target: HTMLElement, color: string): () => void {
};
}
// Wait helper used between ops. Avoids `setTimeout` everywhere.
/** Promise-wrapped setTimeout for use between ops. */
export function sleep(ms: number): Promise<void> {
return new Promise((r) => window.setTimeout(r, ms));
}
@@ -11,43 +11,16 @@ interface Props {
}
const SAFE_PAD = 8;
// Slight bump to APPROX_W to match the larger font — keeps line-wrap
// behavior similar to before. The runtime measures the real rect via
// ref so this is just an initial-mount estimate.
const APPROX_W = 320;
const APPROX_H = 70;
// Distance from the bubble edge to the rounded corner radius — the
// tail's anchor x is clamped between TAIL_PAD and (w - TAIL_PAD) so
// the tail never juts past the corner.
const TAIL_PAD = 16;
// Pokémon-dialog cadence — letters pop in steadily, punctuation gets
// a small extra pause so sentences "land" instead of slurring together.
// Slowed 50% (was 20ms/char) so the popup reads at a more deliberate
// pace, matching the AC cursor's calmer motion.
const STREAM_MS_PER_CHAR = 30;
const STREAM_PUNCT_EXTRA_MS = 210; // after . , ! ? ; : (also +50%)
/** Extra pause after . , ! ? ; : */
const STREAM_PUNCT_EXTRA_MS = 210;
const STREAM_MIN_CHARS = 5;
/**
* Tiny popup that follows the cursor. Non-blocking — no CTA.
*
* Streams text character-by-character like an RPG dialog box (modulo
* very short strings, which appear instantly to avoid visual jank on
* single-word popups).
*
* Positioning: vertical-only — the bubble sits DIRECTLY ABOVE the
* cursor (centered horizontally on the cursor's actual x), with the
* tail pointing down at the target icon. Flips to BELOW the cursor
* only when there isn't room above. This places the popup "over" the
* thing it's referring to instead of beside it, so adjacent siblings
* (toolbar [+ grid globe history note], chat-input [cursor-circle clip
* mic], etc.) are never covered by the bubble's body.
*
* The tail anchors at the cursor's actual x relative to the bubble's
* (possibly clamped) left edge, so it still points at the icon even
* when the bubble is shifted by the viewport-edge clamp.
*/
/** Non-blocking cursor popup; streams char-by-char above the cursor (flips below if no room). */
const ACPopup: React.FC<Props> = ({ text, offset = { x: 0, y: 14 } }) => {
const c = useClaudeTokens();
const { x, y, visible } = useCursorPosition();
@@ -64,19 +37,7 @@ const ACPopup: React.FC<Props> = ({ text, offset = { x: 0, y: 14 } }) => {
flipY: true,
});
// Streaming text state — grows from 0 to text.length char-by-char.
// Use chained setTimeout (not setInterval) so we can vary the delay
// per character — punctuation gets an extra beat, mimicking the
// pacing of Pokémon-style dialog boxes where sentences "land."
//
// Diagnostic popups (anything containing the literal `[debug]`
// marker) skip streaming entirely. The recovery popup that fires on
// step failure carries a `[debug] <error message>` suffix so the
// user can see WHY a step bailed without opening DevTools — but at
// 30 ms/char + 210 ms per punctuation, the suffix takes the full
// 14 s popup duration to even start rendering, so by the time the
// user reads it the popup is already gone. Instant-render for these
// means the diagnostic appears immediately.
// [debug] popups skip streaming so the diagnostic suffix is visible immediately.
const isDebugPopup = text.includes('[debug]');
const skipStream = isDebugPopup || text.length < STREAM_MIN_CHARS;
const [streamCount, setStreamCount] = useState<number>(
@@ -97,9 +58,7 @@ const ACPopup: React.FC<Props> = ({ text, offset = { x: 0, y: 14 } }) => {
timer = null;
return;
}
// Look at the char we *just* revealed — if it's punctuation,
// wait an extra beat before the next one. Mirrors Pokémon's
// "..." and end-of-sentence pacing.
// Punctuation we just revealed gets an extra beat.
const justShown = text[i - 1];
const isPunct = /[.,!?;:]/.test(justShown);
const delay = STREAM_MS_PER_CHAR + (isPunct ? STREAM_PUNCT_EXTRA_MS : 0);
@@ -118,8 +77,6 @@ const ACPopup: React.FC<Props> = ({ text, offset = { x: 0, y: 14 } }) => {
const vw = window.innerWidth;
const vh = window.innerHeight;
// Default: bubble centered on cursor's x, sitting above the cursor.
// Flip below only when there isn't room above.
let nx = x - w / 2;
let ny = y - h - offset.y;
let flipY = true;
@@ -128,10 +85,7 @@ const ACPopup: React.FC<Props> = ({ text, offset = { x: 0, y: 14 } }) => {
flipY = false;
}
// Horizontal clamp — keep the bubble on-screen. The tail's anchor x
// is computed AFTER clamping so the tail always points at the
// cursor's actual position even when the bubble has been shoved
// inward by the viewport edge.
// Tail anchor x is computed AFTER clamp so it still points at the cursor when bubble shifts.
const nxClamped = Math.max(SAFE_PAD, Math.min(nx, vw - w - SAFE_PAD));
const nyClamped = Math.max(SAFE_PAD, Math.min(ny, vh - h - SAFE_PAD));
const tailRaw = x - nxClamped;
@@ -143,8 +97,7 @@ const ACPopup: React.FC<Props> = ({ text, offset = { x: 0, y: 14 } }) => {
if (!visible) return null;
const displayText = text.slice(0, streamCount);
// Reserve full width with invisible char to prevent the bubble from
// jiggling as letters arrive — invisible character keeps wrap consistent.
// Reserve full width with invisible chars so the bubble doesn't jiggle as letters arrive.
const isStreaming = streamCount < text.length;
return (
@@ -160,9 +113,7 @@ const ACPopup: React.FC<Props> = ({ text, offset = { x: 0, y: 14 } }) => {
}}
exit={{ opacity: 0, scale: 0.85 }}
transition={{
// Slowed 50% from {0.14, stiffness 320, damping 32} — gives the
// bubble a more deliberate arrival, in sync with the cursor's
// gentler spring.
// Slowed 50% from {0.14, 320, 32}; matches cursor spring.
opacity: { duration: 0.21 },
scale: { duration: 0.21 },
x: { type: 'spring', stiffness: 160, damping: 22 },
@@ -191,10 +142,7 @@ const ACPopup: React.FC<Props> = ({ text, offset = { x: 0, y: 14 } }) => {
fontFamily: c.font.sans,
}}
>
{/* Tail pointing back at the cursor. Centered on the cursor's
actual x (via tailLeft) so the diamond's point lands on the
target icon, regardless of whether the bubble itself was
shifted by the viewport clamp. */}
{/* Tail anchored on cursor's actual x via tailLeft; lands on target despite bubble clamp. */}
<Box
sx={{
position: 'absolute',
@@ -206,11 +154,7 @@ const ACPopup: React.FC<Props> = ({ text, offset = { x: 0, y: 14 } }) => {
top: pos.flipY ? 'auto' : -5,
bottom: pos.flipY ? -5 : 'auto',
left: pos.tailLeft - 5,
// flipY=true bubble is above cursor, tail at bubble's
// bottom edge → bottom-right corner borders visible so the
// diamond points down at the cursor.
// flipY=false → bubble is below cursor, tail at top edge →
// top-left corner borders visible, diamond points up.
// flipY true: bubble above, tail at bottom (br corners visible, points down). flipY false flips.
borderRight: pos.flipY ? `1px solid ${c.accent.primary}` : 'none',
borderBottom: pos.flipY ? `1px solid ${c.accent.primary}` : 'none',
borderTop: pos.flipY ? 'none' : `1px solid ${c.accent.primary}`,
@@ -219,9 +163,7 @@ const ACPopup: React.FC<Props> = ({ text, offset = { x: 0, y: 14 } }) => {
/>
<Typography
sx={{
// Sized to feel like a Pokémon dialog — small but firm.
// 0.85rem reads cleanly without dominating the screen,
// and pairs with the bolder weight to stay legible.
// 0.85rem with bold weight reads cleanly without dominating.
fontSize: '0.85rem',
color: c.text.primary,
fontWeight: 600,
@@ -1,18 +1,6 @@
// Type a string into a target input or contentEditable element one character
// at a time, dispatching events that React's reconciler observes so the
// product's controlled input state stays in sync.
//
// React intercepts native value setters on <input>/<textarea> via a
// prototype-level descriptor, then dispatches 'input' events to its own
// synthetic event system. To make a fake change visible to React, we
// have to invoke the native setter via the prototype descriptor and then
// dispatch a real 'input' event. Setting `el.value = ...` directly is
// silently ignored by React's onChange.
// Type into input/textarea/contentEditable using React-prototype native setters so onChange fires.
// Version marker so we can verify the dev bundle actually reloaded after
// editing this file. Check `window.__OPENSWARM_TYPEINTO__` in DevTools
// — if it's missing or shows an older tag, Electron's renderer is
// running a cached bundle and needs a Cmd+R hard-reload.
// Bundle-version marker; check window.__OPENSWARM_TYPEINTO__ to confirm dev-reload landed.
if (typeof window !== 'undefined') {
(window as any).__OPENSWARM_TYPEINTO__ = 'v2-dom-direct-2026-05-12';
}
@@ -50,32 +38,10 @@ function dispatchInput(el: HTMLElement): void {
el.dispatchEvent(new Event('input', { bubbles: true }));
}
// contentEditable fields (the agent chat input is one) need a different
// path than <input>/<textarea>. Setting textContent nukes rich-content
// children (skill pills, etc), so we append a Text node at the end and
// dispatch a real InputEvent that React's reconciler treats as a
// keystroke. We used to call document.execCommand('insertText') here
// instead — that's the "idiomatic" way to programmatically type into a
// contentEditable — but in Electron with a webview loaded in the
// preview pane (App Builder step 8 / step 5 / step 6 all hit this),
// the webview steals document focus during its load. execCommand
// requires the host document to be focused AND the active element to
// be editable; without focus it silently no-ops while still returning
// true, so the wizard's `typeInto` "succeeded" but no characters ever
// landed, hasContent stayed false on the chat input, the send button
// never rendered, and step 8's `move_to chatSendButton` then burned
// its 15 s waitForSelector and threw into the recovery popup. The
// AC's "cursor" is purely visual — it never fires real focus events
// — so there's no way to get document focus back without the user
// clicking. DOM-level insertion + dispatched InputEvent works
// regardless of focus state.
// contentEditable: append a Text node + dispatch InputEvent; execCommand silently no-ops when a webview steals focus.
function insertContentEditableText(el: HTMLElement, ch: string): void {
el.focus();
// Append at the very end of the editable. Walk to the deepest
// last-text-node so we don't insert into the middle of a skill pill
// wrapper (those are inline-block element children with their own
// text). If the last child is an element (e.g., a <span> skill
// pill), we append a sibling text node after it.
// Append at the very end; walk past skill-pill spans by appending a sibling text node.
const range = document.createRange();
const last = el.lastChild;
if (last && last.nodeType === Node.TEXT_NODE) {
@@ -95,10 +61,7 @@ function insertContentEditableText(el: HTMLElement, ch: string): void {
sel.removeAllRanges();
sel.addRange(range);
}
// React's controlled-input bridge listens for `input` events. The
// `inputType: insertText` + `data: ch` mirrors what a real keystroke
// produces, so handleInput → updateHasContent fires and hasContent
// flips true → the send button finally renders.
// inputType:insertText + data:ch mirrors a real keystroke so React's handleInput fires.
el.dispatchEvent(
new InputEvent('input', {
bubbles: true,
@@ -111,8 +74,7 @@ function insertContentEditableText(el: HTMLElement, ch: string): void {
export interface TypeIntoOptions {
speedMs?: number;
// Optional callback fired after each character — lets the cursor
// re-align to the input's right edge as text grows.
/** Per-char callback so the cursor can re-align to the input's right edge as text grows. */
onTick?: () => void;
}
@@ -129,17 +91,11 @@ export async function typeInto(
text: string,
opts: TypeIntoOptions = {},
): Promise<void> {
// Default char-cadence — faster than the original 40ms (which felt
// like watching molasses for long URLs). 18ms is still slow enough to
// read live but doesn't make typing the main bottleneck of the step.
// 18ms default; readable without making typing the bottleneck.
const speed = opts.speedMs ?? 18;
el.focus();
// Per-character cadence is constant (no jitter — variable timing reads
// as glitchy, not natural). The one exception: insert a natural-reading
// pause after a comma / sentence-terminator / colon / semicolon so the
// streamed text breathes the way a human would. Anything else types at
// the constant `speed` value, beat by beat.
// Constant cadence (jitter reads glitchy); only punctuation gets a longer pause to breathe.
const punctPause = (ch: string): number => {
if (ch === ',') return 220;
if (ch === '.' || ch === '!' || ch === '?') return 320;
@@ -147,9 +103,6 @@ export async function typeInto(
return 0;
};
// Branch on element kind. contentEditable (the agent ChatInput uses
// a contentEditable div for skill-pill support) requires execCommand;
// <input>/<textarea> require the React-prototype-setter dance.
if (el.isContentEditable) {
for (const ch of text) {
insertContentEditableText(el, ch);
@@ -170,16 +123,7 @@ export async function typeInto(
}
}
// Post-type verification. Under heavy main-thread load (many agents
// streaming concurrently), execCommand('insertText') can silently
// no-op while React's reconciler is starved — AC "types" but the
// characters never land in the controlled input. Without this check,
// step 8 (App Builder) would "complete" with an empty draft and the
// user would see no app get built.
//
// After typing, give React up to 500ms to commit, then re-read the
// effective text. If it's missing most of what we typed, fall back
// to a single-shot insert that's much more reliable under load.
// Verify post-typing under load: if React's reconciler dropped chars, fall back to single-shot insert.
const target = text.trim();
if (!target) return;
for (let i = 0; i < 5; i++) {
@@ -188,8 +132,7 @@ export async function typeInto(
if (got.length >= Math.floor(target.length * 0.8)) return;
}
// Fallback: nuke contents and insert the full string in one shot.
// Loses the typing animation but preserves the user-visible outcome.
// Fallback: nuke contents and insert in one shot; loses animation, preserves outcome.
try {
if (el.isContentEditable) {
el.focus();
@@ -229,6 +172,6 @@ export async function typeInto(
dispatchInput(el);
}
} catch {
/* best-effort runtime's wait_user will time out and recover */
/* best-effort; runtime's wait_user will time out and recover */
}
}
@@ -29,38 +29,12 @@ export interface AgenticCursorHandle {
transition?: Record<string, unknown>,
) => Promise<void>;
pressClick: () => Promise<void>;
/**
* Lock the cursor to a live data-onboarding selector. After this is
* called the cursor re-resolves the selector and re-reads its rect on
* every animation frame, pinning itself (and any attached popup) to
* the element's current center. Survives reflows, scrolls, sidebar
* collapses, and React node swaps (uninstalled-card → installed-card,
* etc.) — the cursor follows the live target instead of stranding
* itself at the rect we read at the time of move_to.
*
* Pass an offset to override the default (center-of-rect). Calling
* startTracking again replaces any prior tracker; the next op that
* physically moves the cursor (move_to / click / type_into /
* drag_select / outro) calls stopTracking automatically.
*/
/** Pin cursor to a live selector; rAF re-resolves so it follows reflows + React node swaps. */
startTracking: (selector: string, offset?: { x: number; y: number }) => void;
stopTracking: () => void;
/**
* Show a non-blocking popup above the cursor. Returns immediately;
* the popup stays visible until hidePopup() is called or another
* showPopup replaces it. The runtime calls hidePopup() before any op
* that physically moves the cursor or types, so the popup naturally
* disappears when the cursor's "instruction" no longer applies.
*
* Placement is fixed: bubble centered on the cursor's x, sitting
* directly above the cursor (auto-flips below if no room above).
* See ACPopup for the full positioning logic.
*/
/** Non-blocking popup above cursor; auto-clears on next physical-move op. */
showPopup: (text: string) => void;
/**
* Single-select multi-choice. Resolves with the chosen option id; the
* panel that calls this can route the rest of the flow accordingly.
*/
/** Single-select multi-choice; resolves with the chosen option id. */
showMultiChoice: (q: string, opts: ACMultiChoiceOption[]) => Promise<string>;
hidePopup: () => void;
getPosition: () => { x: number; y: number };
@@ -76,11 +50,7 @@ interface MultiChoiceState {
resolve: (id: string) => void;
}
// Snappy spring — back to the tight 260/26 from before the 50%
// slowdown. The "calm" feel of the AC now comes from the popup's
// slower typewriter cadence + the 3s dwell floor; the cursor itself
// stays responsive so bubble-less moves (move_to → click, move_to →
// type_into, the canvas-controls tour) don't feel sluggish.
// Snappy 260/26 spring; calm comes from popup cadence + 3s dwell, not cursor delay.
const SPRING = { type: 'spring' as const, stiffness: 260, damping: 26 };
const AgenticCursor = forwardRef<AgenticCursorHandle>((_props, ref) => {
@@ -91,19 +61,14 @@ const AgenticCursor = forwardRef<AgenticCursorHandle>((_props, ref) => {
const [popup, setPopup] = useState<PopupState | null>(null);
const [multiChoice, setMultiChoice] = useState<MultiChoiceState | null>(null);
// Active sticky-tracker handle. Set by startTracking, cleared by
// stopTracking. Survives renders via ref so the rAF loop can be
// cancelled cleanly even if the component re-renders mid-flight.
const trackerRef = useRef<{ stop: () => void } | null>(null);
// Mirror the cursor's logical position into the cursorStore so popups
// can follow without re-running through Framer's animation pipeline.
// Mirrored into cursorStore so popups follow without re-running through Framer's animation pipeline.
const writePos = (x: number, y: number, vis = true) => {
posRef.current = { x, y };
cursorStore.set({ x, y, visible: vis });
};
// Stop any sticky tracker. Idempotent.
const stopTrackingInternal = () => {
if (trackerRef.current) {
trackerRef.current.stop();
@@ -111,10 +76,7 @@ const AgenticCursor = forwardRef<AgenticCursorHandle>((_props, ref) => {
}
};
// Defensive: if the AC unmounts mid-flow (Director.detach, panel
// hidden), the rAF callback would otherwise keep firing and pinning a
// dead component's `controls` to the live target every frame. The
// unmount cleanup cancels it.
// Unmount cleanup: without this the rAF callback keeps pinning a dead component's `controls` every frame after Director.detach.
useEffect(() => {
return () => stopTrackingInternal();
}, []);
@@ -132,10 +94,7 @@ const AgenticCursor = forwardRef<AgenticCursorHandle>((_props, ref) => {
});
},
async moveTo(x, y, transition) {
// moveTo is for animated jumps to a fixed coord. Stop any prior
// tracker first so it doesn't keep snapping the cursor back to its
// old anchor mid-animation. The runtime calls startTracking after
// the await resolves, re-pinning to the live target.
// Stop prior tracker so it doesn't snap the cursor back to its old anchor mid-animation.
stopTrackingInternal();
await controls.start({
x,
@@ -166,39 +125,19 @@ const AgenticCursor = forwardRef<AgenticCursorHandle>((_props, ref) => {
const offY = offset?.y ?? 0;
let cancelled = false;
let rafId = 0;
// Cache the resolved node by reference. Re-querying every frame
// would make the cursor flicker between transient duplicate matches
// when React re-renders (e.g. Reddit Card hover state, Switch
// animation, install-toggle transition). Holding the node stable
// means the cursor follows the SAME element through reflows; we
// only re-query when the cached node leaves the document.
// Cache node by reference; re-querying every frame flickers between transient duplicate matches during React re-renders.
let cachedEl: HTMLElement | null = resolveSelector(selector);
let lastX = posRef.current.x;
let lastY = posRef.current.y;
// Lost-target tracking. If the cached element disconnects (user
// navigates away, collapses the section, etc) and we can't re-find
// it for >LOST_TIMEOUT_MS, fire the lost-target event so the
// runtime can outro gracefully and offer a recovery hint.
let lostSinceMs: number | null = null;
const LOST_TIMEOUT_MS = 2500;
const EPSILON = 0.5;
// Drop frames where the resolved rect would teleport the cursor by
// more than this. Real reflows move elements a few px per frame;
// 600px instantly is a sign of a stale/transient rect mid-commit.
// 600px+ rect jump in one frame = stale/transient mid-commit, not a real reflow.
const MAX_JUMP_PX = 600;
// Title-bar drag region (38px in AppShell). Pinning the cursor
// there lands it on the macOS traffic lights / Electron drag-area
// — never an intentional onboarding target. Skip those frames.
const TITLE_BAR_BOTTOM = 38;
// Throttle the rAF tracker to ~30fps. The browser fires rAF at the
// monitor refresh (60-144Hz typically), and re-querying rects +
// applying transforms every single frame is wasted work for what
// is fundamentally a "follow this rect" loop. 30fps still feels
// glued because the visible jitter threshold for static UI is
// higher than for animated UI. Halves rAF callback cost during
// pinned ops.
// ~30fps; per-frame rect reads are wasted for "follow this rect."
let lastTickAt = 0;
const TICK_INTERVAL_MS = 33; // ~30fps
const TICK_INTERVAL_MS = 33;
const tick = () => {
if (cancelled) return;
const now = performance.now();
@@ -211,17 +150,11 @@ const AgenticCursor = forwardRef<AgenticCursorHandle>((_props, ref) => {
if (!cachedEl || !cachedEl.isConnected) {
cachedEl = resolveSelector(selector);
if (!cachedEl) {
// Element vanished. Start (or continue) the lost-target
// countdown — once we exceed the timeout, signal the
// runtime to abort.
const now = Date.now();
if (lostSinceMs === null) lostSinceMs = now;
if (now - lostSinceMs > LOST_TIMEOUT_MS) {
cancelled = true;
cancelAnimationFrame(rafId);
// Custom event the runtime listens for. Decoupled from
// controls/Promise machinery so we can fire from inside
// a rAF tick without races.
window.dispatchEvent(
new CustomEvent('openswarm:onboarding:lost_target', {
detail: { selector },
@@ -230,7 +163,6 @@ const AgenticCursor = forwardRef<AgenticCursorHandle>((_props, ref) => {
return;
}
} else {
// Re-acquired — clear the countdown.
lostSinceMs = null;
}
} else {
@@ -242,11 +174,7 @@ const AgenticCursor = forwardRef<AgenticCursorHandle>((_props, ref) => {
if (r.width > 0 || r.height > 0) {
const cx = r.left + r.width / 2 + offX;
const cy = r.top + r.height / 2 + offY;
// Viewport guards: skip frames where pinning would land the
// cursor outside the visible window OR inside the title-bar
// drag region. These don't help the user — they're symptoms
// of a stale read or a hidden/overflowed target — and the
// next legitimate frame will pin correctly.
// Off-window / title-bar frames are stale-reads or hidden targets.
const offWindow =
cx < 0 ||
cy < 0 ||
@@ -280,9 +208,6 @@ const AgenticCursor = forwardRef<AgenticCursorHandle>((_props, ref) => {
stopTrackingInternal();
},
showPopup(text) {
// Non-blocking — replaces any existing popup. Caller advances the
// flow; popup auto-clears on the next op that physically moves the
// cursor (move_to / click / type_into / drag_select / outro).
setPopup({ text });
},
showMultiChoice(question, options) {
@@ -300,9 +225,7 @@ const AgenticCursor = forwardRef<AgenticCursorHandle>((_props, ref) => {
hidePopup() {
setPopup(null);
if (multiChoice) {
// Defensive — multi_choice is supposed to resolve via user pick,
// but if the runtime aborts mid-question we don't want a dangling
// promise. Resolve with '' so callers can detect dismissal.
// Resolve with '' on abort so the promise doesn't dangle.
multiChoice.resolve('');
setMultiChoice(null);
}
@@ -316,15 +239,13 @@ const AgenticCursor = forwardRef<AgenticCursorHandle>((_props, ref) => {
return createPortal(
<>
{/* Cursor body — animated by Framer Motion. pointer-events:none so it
never blocks user interaction with the underlying app. */}
{/* pointer-events:none so the cursor never blocks underlying app interaction. */}
<motion.div
animate={controls}
onUpdate={(latest) => {
const x = typeof latest.x === 'number' ? latest.x : posRef.current.x;
const y = typeof latest.y === 'number' ? latest.y : posRef.current.y;
// Avoid React re-renders on every frame; just push to the external
// store so popups (which subscribe via useSyncExternalStore) follow.
// Push to external store instead of re-rendering; popups subscribe via useSyncExternalStore.
if (visible) cursorStore.set({ x, y });
}}
style={{
@@ -333,19 +254,12 @@ const AgenticCursor = forwardRef<AgenticCursorHandle>((_props, ref) => {
left: 0,
zIndex: 10500,
pointerEvents: 'none',
// Translate origin: top-left of viewport. The animated x/y is the
// cursor tip's logical position.
transformOrigin: 'top left',
// Visual offset so the arrow's "tip" sits at (x,y) — the SVG below
// is drawn from its top-left, so shift it slightly up-and-left to
// align the pointer.
}}
>
{visible && (
<motion.div
animate={{
// Subtle idle pulse — closer to a soft heartbeat than a
// bouncing scale. Stays out of the way visually.
scale: [1, 1.04, 1],
}}
transition={{
@@ -355,9 +269,7 @@ const AgenticCursor = forwardRef<AgenticCursorHandle>((_props, ref) => {
}}
style={{
transform: 'translate(-2px, -2px)',
// Two-layer glow: tight inner ring + softer outer halo.
// Tuned so the cursor reads clearly against light AND dark
// canvases without being distracting.
// Tight inner ring + soft outer halo reads on light AND dark canvases.
filter: `drop-shadow(0 0 6px ${c.accent.primary}cc) drop-shadow(0 0 14px ${c.accent.primary}55)`,
}}
>
@@ -366,9 +278,7 @@ const AgenticCursor = forwardRef<AgenticCursorHandle>((_props, ref) => {
)}
</motion.div>
{/* Popups portaled separately so their pointer-events:auto isn't
inherited from the cursor wrapper's pointer-events:none. They
subscribe to cursorStore to track the live position. */}
{/* Portaled separately so cursor wrapper's pointer-events:none doesn't propagate. */}
<AnimatePresence>
{popup && <ACPopup key="popup" text={popup.text} />}
{multiChoice && (
@@ -388,7 +298,7 @@ const AgenticCursor = forwardRef<AgenticCursorHandle>((_props, ref) => {
AgenticCursor.displayName = 'AgenticCursor';
export default AgenticCursor;
// Standard arrow cursor shape — 22x22, drawn pointing down-right.
/** 22x22 arrow cursor, points down-right. */
const CursorArrow: React.FC<{ color: string }> = ({ color }) => (
<svg
width="22"
@@ -1,11 +1,4 @@
// AC runtime — executes a step's ACOp[] sequence by calling into the
// AgenticCursor handle and the gesture/typing helpers. Runs ops sequentially
// with `await`; aborts cleanly when the AbortSignal fires (user dismisses
// panel mid-step, opens a different step, etc).
//
// Pure async. Not a class. Director (in OnboardingDirector.ts) is the
// caller — it owns the lifecycle (AbortController, AC ref, accent color
// resolution from the theme).
/** AC runtime: sequentially awaits a step's ACOp[] via the AgenticCursor handle; aborts on AbortSignal. */
import type { Store } from '@reduxjs/toolkit';
import type { RootState } from '@/shared/state/store';
@@ -18,7 +11,6 @@ import {
} from '../OnboardingProgressSlice';
import { report, markStepStarted, clearStepTiming } from '../telemetry';
import { onboardingBus, type OnboardingEvent } from '../eventBus';
// (gate bump done via onboardingBus.resetReplayGate at runStep entry)
import { waitForSelector, resolveSelector } from '../selectors';
import {
spawnGlowRect,
@@ -42,29 +34,14 @@ interface RunContext {
signal: AbortSignal;
silent: boolean; // suppress popups during dependency re-walks
stepId: string;
// Resolver function for finding a step by id (avoids circular import).
findStep: (id: string) => OnboardingStep | undefined;
// Cleanup for the highlight_section big glow.
highlightCleanup: { current: (() => void) | null };
// Wall-clock timestamp the current popup was shown at, or null if no
// popup is active. Used by ensurePopupDwell to guarantee every popup
// stays visible for at least MIN_POPUP_DWELL_MS before being replaced
// or cleared by the next auto-transition op.
popupShownAt: { current: number | null };
}
// Minimum time every popup stays visible before an auto-transition
// (move_to, click, type_into, drag_select, outro) or a popup replacement
// is allowed to clear it. user-driven transitions (wait_user resolving)
// also flow through here, but typically the user has already been
// reading for longer than this anyway. 6 s = streaming typewriter
// cadence + ~3 s post-stream read time, which was the user-asked floor
// for popups that don't require an explicit user action to advance.
// 6s = streaming typewriter cadence + ~3s post-stream read time; floor for popups that auto-transition without an explicit user action.
const MIN_POPUP_DWELL_MS = 6000;
// Resolves once `ms` has elapsed or the signal aborts (whichever
// comes first). Used inside ensurePopupDwell so a step cancel doesn't
// hang on a popup that just appeared.
function abortableSleep(ms: number, signal: AbortSignal): Promise<void> {
if (ms <= 0) return Promise.resolve();
if (signal.aborted) return Promise.resolve();
@@ -81,8 +58,6 @@ function abortableSleep(ms: number, signal: AbortSignal): Promise<void> {
});
}
// Awaits the remaining minimum dwell time for the currently-displayed
// popup. No-op if no popup is active or the dwell has already elapsed.
async function ensurePopupDwell(ctx: RunContext): Promise<void> {
const shownAt = ctx.popupShownAt.current;
if (shownAt == null) return;
@@ -99,9 +74,6 @@ export interface RunStepArgs {
accentColor: string;
signal: AbortSignal;
findStep: (id: string) => OnboardingStep | undefined;
// Optional gate — if step.dependsOn[i] doesn't need re-walking (the
// dependency's outcome is still satisfied), the caller passes a function
// that returns true to skip it.
isDependencySatisfied?: (depId: string) => boolean;
}
@@ -111,10 +83,7 @@ export async function runStep(args: RunStepArgs): Promise<void> {
store.dispatch(setRunning(true));
store.dispatch(setCurrentStep(step.id));
markStepStarted();
// Bump the bus replay gate so any cached emits from prior steps (or
// the user's exploration in between) can't accidentally satisfy this
// step's wait_user gates. Subsequent once() subscriptions will only
// match emits that happen AFTER this bump.
// Bump bus replay gate so cached emits from prior steps can't satisfy this step's wait_user gates.
onboardingBus.resetReplayGate();
report('step_started', { step_id: step.id, stage: step.stage });
@@ -136,11 +105,7 @@ export async function runStep(args: RunStepArgs): Promise<void> {
try {
await ac.fadeIn(spawnPoint);
// Pre-flight: if the step needs a dashboard route and the user is on
// a different page (Settings closed but they're on /actions, /skills,
// etc), walk them into a dashboard first. Without this, the very
// first move_to of step 3/4/5/6/8 hits a missing target and the
// cursor stalls or strands itself over unrelated UI.
// Walk user into a dashboard first when step needs one; otherwise the first move_to hits a missing target on /actions, /skills, etc.
if (step.requiresDashboard && !isInDashboardRoute()) {
await runOps(buildOpenDashboardOps(), ctx);
}
@@ -152,18 +117,10 @@ export async function runStep(args: RunStepArgs): Promise<void> {
if (!depStep) continue;
if (dep.reopen === 'walk_again') {
report('dependency_walk', { step_id: step.id, dep_id: dep.stepId });
// 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).
// Non-silent dep-walk so each move_to has a label; telemetry stays per-step to avoid double-count.
await runOps(depStep.ops, { ...ctx, silent: false, stepId: depStep.id });
}
}
@@ -172,13 +129,6 @@ export async function runStep(args: RunStepArgs): Promise<void> {
await runOps(step.ops, ctx);
report('step_completed', { step_id: step.id });
store.dispatch(markStepCompleted(step.id));
// Belt-and-suspenders: dispatch clearJustCompleted from the runtime
// 950ms after the celebration starts. The OnboardingPanel ALSO has
// its own useEffect timer for this, but the runtime-side timer
// guarantees the celebration unsticks even if the panel's effect
// gets cancelled by a re-render race or AnimatePresence interaction
// — both dispatches go through the same idempotent reducer, so
// double-firing is harmless.
window.setTimeout(() => {
const cur = store.getState().onboardingProgress;
if (cur?.justCompletedStepId === step.id) {
@@ -200,10 +150,7 @@ export async function runStep(args: RunStepArgs): Promise<void> {
report('step_error', { step_id: step.id, error: msg });
}
// Re-show the panel IMMEDIATELY so the user sees it slide back in
// alongside the cursor's friendly retreat. Otherwise the panel
// stays hidden through the 1.8s recovery popup + fadeOut, which
// looks like the onboarding has crashed.
// Re-show panel immediately; otherwise it stays hidden through the 1.8s recovery popup + fadeOut, looking like a crash.
store.dispatch(setRunning(false));
try {
@@ -215,9 +162,7 @@ export async function runStep(args: RunStepArgs): Promise<void> {
}
const showMessage = !signal.reason || signal.reason !== 'user-cancel';
if (showMessage) {
// Diagnostic: surface a short version of the actual error in
// the recovery popup so we can see WHY the step bailed without
// needing DevTools open. 180-char cap keeps it readable.
// Surface short error in recovery popup; 180-char cap keeps it readable.
const isAbortErr =
(err as DOMException)?.name === 'AbortError' || signal.aborted;
const errSnippet = isAbortErr
@@ -226,11 +171,7 @@ export async function runStep(args: RunStepArgs): Promise<void> {
const debugSuffix = errSnippet
? `\n\n[debug] ${errSnippet}`
: '';
// Stash the full error on window so a dev can grab it from
// DevTools (`window.__OPENSWARM_LAST_ONBOARDING_ERR__`) even
// if the streaming popup hides the suffix. Full untruncated
// message + stack lives here, the 180-char snippet is just
// for the popup.
// Stash full untruncated error + stack on window for DevTools; popup only shows the 180-char snippet.
try {
(window as any).__OPENSWARM_LAST_ONBOARDING_ERR__ = {
step_id: step.id,
@@ -246,33 +187,20 @@ export async function runStep(args: RunStepArgs): Promise<void> {
err,
);
} catch {
/* defensive never let diagnostics throw */
/* defensive; never let diagnostics throw */
}
ac.showPopup(
"No worries, feel free to explore. Tap Show me whenever you're ready." +
debugSuffix,
);
// ACPopup streams text at ~30 ms/char + ~210 ms per punctuation
// mark, so a 240-char popup (base copy + 180-char debug
// suffix) takes ~10 s just to finish streaming. With a 5 s
// dwell the [debug] line never even appears on screen before
// the popup closes — which is why the user saw only the base
// recovery copy in every failure run. 14 s gives the streamer
// time to finish AND leaves a few seconds for the user to
// actually read the diagnostic line.
// 14s: ACPopup streams at ~30ms/char + ~210ms/punct, so a 240-char popup takes ~10s to finish streaming; needs time for streamer + read.
await new Promise<void>((r) => window.setTimeout(r, 14000));
}
} catch {
/* defensive never let cleanup throw */
/* defensive; never let cleanup throw */
}
// Retreat to the original spawnPoint — that's the icon's home
// position from before the panel hid itself, and after the
// setRunning(false) above the panel slides back to that exact spot.
// We previously re-read the live icon rect here, but that fires
// mid-slide-animation and yields transient coordinates (sometimes
// (0,0) if Framer hasn't applied the transform yet) — which is
// why the cursor was landing in the title-bar / kill-button area.
// Retreat to original spawnPoint; re-reading the live icon rect here yields transient coords mid-slide-animation (sometimes (0,0)).
try {
await ac.fadeOut(spawnPoint);
} catch {
@@ -294,9 +222,6 @@ async function runOps(ops: ACOp[], ctx: RunContext): Promise<void> {
if (ctx.signal.aborted) {
throw new DOMException('aborted', 'AbortError');
}
// Op-level telemetry — gives drop-off granularity beyond
// step_started / step_completed. Skipped during silent dependency
// re-walks to avoid double-reporting.
if (!ctx.silent) {
report('op_started', {
step_id: ctx.stepId,
@@ -324,10 +249,6 @@ async function runOps(ops: ACOp[], ctx: RunContext): Promise<void> {
duration_ms: Date.now() - opStart,
error: String(err),
});
// Console-visible breadcrumb so a dev with DevTools open can
// see WHICH op of WHICH step blew up without parsing telemetry.
// The catch in runStep above selectively logs based on error
// kind — this is more reliable and pinpoints the failing op.
// eslint-disable-next-line no-console
console.error(
`[onboarding] op failed: step=${ctx.stepId} op#${i}=${op.kind} ` +
@@ -343,13 +264,7 @@ async function runOps(ops: ACOp[], ctx: RunContext): Promise<void> {
async function runOp(op: ACOp, ctx: RunContext): Promise<void> {
const { ac, store, signal, accentColor } = ctx;
// Ops that physically move the cursor or change context implicitly
// clear any active popup, sticky tracker, AND active highlight glow —
// the previous instruction / pin / glow no longer applies once the
// cursor is heading somewhere new. wait_user / delay / popup /
// highlight_section / multi_choice keep all three visible (in
// particular, wait_user keeps tracking so the cursor stays glued to
// its target while we wait for the user's click).
// Physically-moving ops clear popup/tracker/glow; wait_user/delay/popup/highlight_section/multi_choice keep them.
const clearsTransients =
op.kind === 'move_to' ||
op.kind === 'click' ||
@@ -357,12 +272,7 @@ async function runOp(op: ACOp, ctx: RunContext): Promise<void> {
op.kind === 'drag_select' ||
op.kind === 'outro';
if (clearsTransients) {
// Hold the previous popup on screen for MIN_POPUP_DWELL_MS before
// letting the next auto-transition clear it. Without this, a fast
// sequence like `popup → delay 350 → move_to → click` would yank
// the bubble before the user has a chance to read it. wait_user
// gates aren't routed through here because they don't transition
// until the user acts.
// Hold previous popup for MIN_POPUP_DWELL_MS before next auto-transition clears it; otherwise fast popup -> delay -> move_to sequences would yank the bubble before the user can read it.
await ensurePopupDwell(ctx);
ac.hidePopup();
ctx.popupShownAt.current = null;
@@ -375,57 +285,31 @@ async function runOp(op: ACOp, ctx: RunContext): Promise<void> {
switch (op.kind) {
case 'move_to': {
// Pre-flight order matters: open the whole sidebar first (so
// sub-section markers exist in DOM), THEN check the Customization
// collapse, THEN target.
//
// Sidebar collapsed case ("AC freezes when user had sidebar
// hidden") — without this guard, waitForSelector for any
// sidebar-* target would hit its 2.5s lost-target timeout because
// the entire panel is unrendered.
// Order matters: open the whole sidebar first (sub-section markers must exist in DOM), THEN expand Customization, THEN target.
const expandSidebarOps = maybeBuildExpandSidebarOps(op.target);
if (expandSidebarOps) {
await runOps(expandSidebarOps, ctx);
}
// Customization collapsed case ("asks me to click on it twice")
// — without this guard, AC's popup pointed at an Actions/Skills/
// Modes item that wasn't yet visible, the user would click
// Customization to reveal it (which didn't satisfy the wait),
// then click the item, looking like a duplicate prompt.
const expandOps = maybeBuildExpandCustomizationOps(op.target);
if (expandOps) {
await runOps(expandOps, ctx);
}
const el = await waitForSelector(op.target);
const scrolled = scrollIntoViewIfNeeded(el);
// Cheaper rect-settle: instead of unconditionally sleeping 180ms
// after every scroll AND a possible 200ms retry, read the rect
// immediately and only wait if it actually looks bad. In the
// happy path (target already in view, layout stable), this skips
// both sleeps entirely.
const offX = op.offset?.x ?? 0;
const offY = op.offset?.y ?? 0;
const TITLE_BAR_BOTTOM = 38;
// "Truly broken" rect = zero size or pinned in title bar. NOT
// "below viewport" — that just means a smooth-scroll is still in
// progress. Treating below-viewport as degenerate caused step 2
// to abort with the recovery message every time the YouTube row
// was below the fold and AC had to scroll-then-pin.
// Broken = zero size or pinned in title bar; off-viewport just means smooth-scroll is mid-flight (don't treat as broken).
const isBroken = (rr: DOMRect, y: number): boolean =>
y < TITLE_BAR_BOTTOM ||
rr.width === 0 ||
rr.height === 0;
// Off-viewport but valid — element exists, scroll just hasn't
// landed it yet. Worth waiting through, not an abort condition.
const isOffViewport = (y: number): boolean =>
y > window.innerHeight || y < 0;
let r = el.getBoundingClientRect();
let cx = r.left + r.width / 2 + offX;
let cy = r.top + r.height / 2 + offY;
// Active poll for scroll-settle. Smooth-scrolls take 250-500ms;
// poll the rect every 60ms up to 1s. Bails the moment the element
// is in viewport with a non-broken rect, so the happy path stays
// fast (single poll, immediate exit).
// Poll scroll-settle every 60ms up to 1s (smooth-scrolls take 250-500ms); bail early when in viewport with non-broken rect.
const SCROLL_SETTLE_MAX_MS = 1000;
const POLL_MS = 60;
const startedAt = performance.now();
@@ -439,24 +323,10 @@ async function runOp(op: ACOp, ctx: RunContext): Promise<void> {
if (!isBroken(r, cy) && !isOffViewport(cy)) break;
}
}
// Only abort if the rect is BROKEN after the settle window —
// off-viewport at this point means the scroll never landed,
// which usually means the page hasn't fully rendered yet, but
// pinning the cursor off-screen is harmless (user just sees
// nothing land for a moment).
if (isBroken(r, cy)) {
throw new Error(`waitForSelector: "${op.target}" rect did not settle`);
}
// Rect-stability check: when the user clicks "+" to open the
// dock chat, the chat input mounts then nudges into final
// position over a couple frames as siblings render. If we read
// the rect during that window and start the spring immediately,
// the cursor lands on a stale-target location and then the
// tracker has to drag it the remaining ~10-30px — visible as
// a "jump" right after the spring lands. Polling the rect for
// 2 stable consecutive frames (within 1.5px) guarantees we
// start the spring against the FINAL position. Capped at 200ms
// so we never block visibly. Most paths break out in 0-2 frames.
// Wait 2 stable frames before reading final rect; targets like the dock chat input nudge into position over a few frames after mount, and a stale-rect spring lands ~10-30px off and visibly jumps.
const STABILITY_MAX_MS = 200;
const STABILITY_THRESHOLD_PX = 1.5;
const stabilityStart = performance.now();
@@ -483,23 +353,13 @@ async function runOp(op: ACOp, ctx: RunContext): Promise<void> {
prevCy = cy;
}
await ac.moveTo(cx, cy);
// One-frame yield before handing transform control to the
// sticky-tracker rAF. Without this, the tracker's first tick
// can fire while Framer's spring is still settling the final
// ~10px of the move, and the tracker's controls.set() cancels
// the spring mid-overshoot — visible as the cursor "teleporting"
// or disappearing into the destination. A single rAF lets the
// spring resolve before the tracker starts re-pinning every
// frame, which is when the cursor needs to start tracking
// anyway.
// rAF yield lets Framer's spring resolve before tracker's controls.set() cancels it mid-overshoot; otherwise cursor "teleports" into destination.
await new Promise<void>((r) => requestAnimationFrame(() => r()));
ac.startTracking(op.target, op.offset);
return;
}
case 'popup': {
if (ctx.silent) return;
// Replacing a popup-with-popup also has to honor the dwell floor,
// otherwise back-to-back popups would flash by too fast to read.
await ensurePopupDwell(ctx);
ac.showPopup(op.text);
ctx.popupShownAt.current = performance.now();
@@ -507,7 +367,6 @@ async function runOp(op: ACOp, ctx: RunContext): Promise<void> {
}
case 'multi_choice': {
if (ctx.silent) return;
// Multi-choice supersedes any showing popup. Same dwell floor.
await ensurePopupDwell(ctx);
ctx.popupShownAt.current = null;
const id = await ac.showMultiChoice(op.question, op.options);
@@ -529,33 +388,21 @@ async function runOp(op: ACOp, ctx: RunContext): Promise<void> {
}
case 'highlight_section': {
const el = await waitForSelector(op.target);
// Replace any previous highlight first so we don't stack glows.
if (ctx.highlightCleanup.current) {
ctx.highlightCleanup.current();
ctx.highlightCleanup.current = null;
}
const cleanup = spawnGlowRect(el, accentColor);
ctx.highlightCleanup.current = cleanup;
// Only show the popup if one was supplied — the runtime relies on
// the next op (typically wait_user) to keep the glow visible while
// the user reads. The glow is cleared by the next clearsTransients
// op (move_to / click / type_into / drag_select / outro) or at
// step-end in the runStep finally block.
if (op.popup && !ctx.silent) {
await ensurePopupDwell(ctx);
ac.showPopup(op.popup);
ctx.popupShownAt.current = performance.now();
}
// Optional minimum dwell so very-fast paths still register the
// glow visually. Defaults to a short beat; explicit durationMs
// overrides.
await sleep(op.durationMs ?? 600);
return;
}
case 'type_into': {
// Resolve text up-front — 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;
const targetTrimmed = resolvedText.trim();
@@ -567,18 +414,7 @@ async function runOp(op: ACOp, ctx: RunContext): Promise<void> {
return (e.textContent ?? '').trim();
};
// Type-and-verify is wrapped in a retry loop because the App
// Builder's chat input can be detached out from under us mid-
// stream: the workspace's `runtime/start → stop → start` cycle +
// ViewEditor's seed-then-navigate causes React to swap the
// AgentChat instance the user can see, leaving the element our
// `el` ref points at detached from the DOM. execCommand fires
// silently into the dead node, no text lands, hasContent stays
// false, and the send button never renders — which is what was
// pushing the wizard into the recovery popup. On a verify-miss
// we re-fetch the selector (which now resolves to the FRESH
// AgentChat's input) and type again. Two attempts is the max —
// a real "the input is genuinely broken" case shouldn't loop.
// Retry loop: App Builder's ViewEditor remounts can detach the chat input mid-type; re-fetch selector and retype.
const MAX_ATTEMPTS = 3;
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
const el = await waitForSelector(op.target);
@@ -593,38 +429,28 @@ async function runOp(op: ACOp, ctx: RunContext): Promise<void> {
ac.startTracking(op.target, { x: 0, y: 0 });
await typeInto(el, resolvedText, { speedMs: op.speedMs });
// Let React's onInput commit land before verifying. 80 ms is
// enough in the warm-path; we sleep longer between retries
// because a remount window is what we're racing.
// 80ms lets React's onInput commit land in the warm path.
await sleep(80);
if (!targetTrimmed) return;
// Re-fetch in case the original `el` was detached by a remount.
// resolveSelector will return whatever the CURRENT canonical
// chat-input is in the scope priority order.
// Re-fetch in case original `el` was detached by remount.
const currentEl = resolveSelector(op.target);
const verifyEl = currentEl ?? el;
const landed = readText(verifyEl);
if (landed.length >= Math.floor(targetTrimmed.length * 0.8)) {
// Success — text is in the live input.
return;
}
if (attempt < MAX_ATTEMPTS) {
// eslint-disable-next-line no-console
console.warn(
`[onboarding] type_into verify-miss for "${op.target}" attempt ${attempt}/${MAX_ATTEMPTS} typed=${landed.length}/${targetTrimmed.length}, retrying`,
`[onboarding] type_into verify-miss for "${op.target}" attempt ${attempt}/${MAX_ATTEMPTS}; typed=${landed.length}/${targetTrimmed.length}, retrying`,
);
// Wait long enough for any in-flight remount + reconcile to
// settle. 600 ms is longer than the ~500 ms stability window
// wait_for_dom uses, so by the time we retry the DOM is in
// its steady state.
// 600ms > the ~500ms stability window wait_for_dom uses, so DOM is in steady state by retry.
await sleep(600);
continue;
}
// Final attempt — same single-shot re-insert the old anti-
// revert guard used, against whatever element is current.
if (verifyEl.isContentEditable) {
verifyEl.focus();
const range = document.createRange();
@@ -646,19 +472,14 @@ async function runOp(op: ACOp, ctx: RunContext): Promise<void> {
verifyEl.dispatchEvent(new Event('input', { bubbles: true }));
}
}
// One last verify after the fallback — if text STILL didn't land,
// throw with a descriptive error so the wizard's catch block
// shows a useful diagnostic instead of letting the next op
// (move_to chatSendButton) burn 15 s on a button that will
// never render because hasContent is false. The thrown message
// appears in DevTools console via the op-failed breadcrumb.
// Throw descriptive error so wizard's catch shows diagnostic instead of letting next op burn 15s on a button that never renders (hasContent=false).
await sleep(120);
const finalLanded = readText(resolveSelector(op.target) ?? verifyEl);
if (finalLanded.length < Math.floor(targetTrimmed.length * 0.5)) {
throw new Error(
`type_into: text never landed in "${op.target}" after ` +
`${MAX_ATTEMPTS} attempts (final length=${finalLanded.length}/${targetTrimmed.length}). ` +
`The chat input was probably detached by an in-flight remount ` +
`The chat input was probably detached by an in-flight remount; ` +
`check whether ViewEditor's seed-then-navigate is firing twice ` +
`or whether AgentChat's session key is swapping mid-stream.`,
);
@@ -678,15 +499,7 @@ async function runOp(op: ACOp, ctx: RunContext): Promise<void> {
await ac.pressClick();
clickRipple(x, y, accentColor);
if (op.simulate !== false) {
// Disabled-button guard. If the resolved element (or any
// ancestor IconButton/Button wrapper) is in a disabled state
// when we go to fire the synthetic click, the click is a
// no-op AND we silently move on — which is the "AC clicks
// send and nothing happens" bug for step 6 (the contentEditable
// chat input sometimes reverts AC's typed text under load,
// leaving the send button disabled at click time). Detect it
// and try a brief recovery: wait one frame and re-check, in
// case the button just-now-enabled because state landed late.
// Disabled-button guard: synthetic click on disabled wrapper is silent no-op (step 6 "send does nothing"); wait one frame in case state lands late.
const isDisabled = (n: HTMLElement | null): boolean => {
while (n) {
if (n.hasAttribute('disabled')) return true;
@@ -704,17 +517,10 @@ async function runOp(op: ACOp, ctx: RunContext): Promise<void> {
try {
el.click();
} catch {
/* swallow degrade to visual-only */
/* swallow; degrade to visual-only */
}
}
// Do NOT start tracking after a click. Many click targets are
// ephemeral — chat send buttons morph into stop buttons after
// submit, modal triggers unmount when the modal opens, etc.
// Tracking a disappearing element triggers lost-target → step
// abort, which kills the step before outro runs and prevents
// markStepCompleted from firing (the user is stuck on the same
// step forever). The cursor's last-set position from moveTo holds
// steady until the next op explicitly moves it.
// Do NOT startTracking after a click: many targets are ephemeral (send button -> stop button, modal trigger unmounts), and tracking a vanishing element trips lost-target -> step abort -> markStepCompleted never fires.
return;
}
case 'drag_select': {
@@ -722,13 +528,7 @@ async function runOp(op: ACOp, ctx: RunContext): Promise<void> {
if (scrollIntoViewIfNeeded(el)) {
await sleep(180);
}
// Rect-stability poll. Without this, the dashed selection box is
// drawn at coordinates read mid-animation — e.g. when step 6
// clicks fit-to-view right before this op, the camera is still
// panning and the target's viewport rect changes frame-to-frame.
// Result: a box that's the wrong size or offset from the actual
// card. Wait for 2 stable consecutive frames (within 1.5px) up
// to 500ms before reading the final rect.
// Wait 2 stable frames before reading final rect; e.g. step 6's fit-to-view mid-pan changes target rect frame-to-frame and yields a misaligned selection box.
let r = el.getBoundingClientRect();
const stableStart = performance.now();
let prevLeft = r.left;
@@ -750,13 +550,7 @@ async function runOp(op: ACOp, ctx: RunContext): Promise<void> {
const toX = r.right + 12;
const toY = r.bottom + 12;
await ac.moveTo(fromX, fromY);
// Run the cursor and the dashed-rect animation in parallel, so the
// cursor visually leads the selection from top-left to bottom-right
// (matching how a real drag works) instead of stranding itself at
// the start corner while the box draws itself across the target.
// The cursor uses a 600ms tween with the same cubic-bezier the rect
// uses (ACGestures.ts) so the two motions stay in lock-step. Spring
// physics here would overshoot and desync from the CSS transition.
// Cursor + rect animate in parallel with matching cubic-bezier (ACGestures.ts) so they stay in lock-step; spring physics would overshoot and desync.
const RECT_DURATION_MS = 600;
await Promise.all([
animateDragSelect(
@@ -769,9 +563,6 @@ async function runOp(op: ACOp, ctx: RunContext): Promise<void> {
ease: [0.4, 0, 0.2, 1],
}),
]);
// No tracking after drag_select — the visual ends at a calculated
// bottom-right corner, not the center of any element. Next op
// (typically wait_user or move_to) takes over positioning.
return;
}
case 'wait_user': {
@@ -781,19 +572,7 @@ async function runOp(op: ACOp, ctx: RunContext): Promise<void> {
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.
// Retry on event_bus timeout only: those fire on real user actions, so silent soft-success would leave them in a half-broken state. click_target + redux_predicate keep soft-success (listener may have just missed).
if (first.timedOut && op.condition.kind === 'event_bus') {
report('wait_user_retry_prompted', {
step_id: ctx.stepId,
@@ -809,34 +588,10 @@ async function runOp(op: ACOp, ctx: RunContext): Promise<void> {
);
}
ac.hidePopup();
// CRITICAL: stop tracking the previous move_to target now that
// the user has engaged with it. Many `wait_user click_target`
// targets are ephemeral — the App Builder's `+ New app` button
// disappears the instant the user clicks it (Views.tsx swaps
// ViewEditor in), and if the tracker keeps watching that now-
// disconnected element, the lost-target watchdog fires after
// 2.5 s and aborts the entire step (step 8 was aborting before
// it ever reached `type_into` for this exact reason — the
// `[onboarding] step make_app aborted: lost-target` console
// line pointed at `apps-new-button`, not at chat-input). The
// tracker for the NEXT target (chat-input, send button, etc.)
// starts in the next move_to / type_into op.
// CRITICAL: stop tracking previous target; many wait_user click_target's are ephemeral (App Builder's "+ New app" unmounts on click) and the 2.5s lost-target watchdog would abort the step before the next op runs.
ac.stopTracking();
// The user just did the thing — they don't need a dwell floor on
// top of having engaged with the popup. Clearing popupShownAt
// makes the next op's clearsTransients block a no-op for dwell,
// so the cursor starts moving toward the next target the instant
// the click registers. Without this, the cursor sat idle for up
// to MIN_POPUP_DWELL_MS while the next op's click listener was
// unregistered — so a quick follow-up click (e.g. clicking the
// chat-input select-mode toggle right after opening the chat)
// was being dropped on the floor, and the user saw "Show me"
// reset because the wait never resolved.
// Clear dwell: user already engaged with popup, so next op can move immediately. Without this, a quick follow-up click was dropped while the next listener was still being registered.
ctx.popupShownAt.current = null;
// Quick layout-settle — one frame is enough in 95% of cases
// (React commits on the next animation frame). The move_to
// op also has its own settle if the rect comes out degenerate,
// so this is just a cheap "let the click handler run" beat.
await sleep(16);
return;
}
@@ -855,19 +610,7 @@ async function runOp(op: ACOp, ctx: RunContext): Promise<void> {
case 'wait_for_dom': {
const timeoutMs = op.timeoutMs ?? 8000;
const POLL_MS = 100;
// Stability gate: the matched element has to be the SAME node for
// STABILITY_POLLS consecutive polls (≈ 500 ms continuous presence)
// before we return success. Without this, step 8 was finding the
// App Builder's chat-input on poll N, returning, then the next
// op's typing ran straight into AgentChat's remount (the
// `runtime/start → stop → start` cycle from a draftLaunchMap swap
// + React Strict Mode double-effect) — the input became detached
// mid-stream, execCommand('insertText') silently no-op'd into the
// dead node, no text landed, hasContent stayed false, the send
// button was never rendered, and the wizard's next move_to
// chatSendButton burned its 15 s waitForSelector and threw into
// the recovery popup. Requiring stable identity walls off the
// remount window so we only proceed once the runtime has settled.
// Stability gate: same node identity for STABILITY_POLLS consecutive polls (~500ms) walls off AgentChat's runtime/start->stop->start remount; otherwise typing lands in a detached node and silently no-ops.
const STABILITY_POLLS = 5;
const startedAt = performance.now();
let stableEl: Element | null = null;
@@ -891,11 +634,7 @@ async function runOp(op: ACOp, ctx: RunContext): Promise<void> {
}
await sleep(POLL_MS);
}
// Hard error on timeout, with DOM-state diagnostics so the dev
// console tells us WHY the selector didn't match — bare selector
// mismatch vs. the marker being on the right element but the
// wrong scope vs. nothing in DOM at all are three different bugs
// and we couldn't tell which from "step failed".
// Timeout error includes scope diagnostics: selector-mismatch vs. wrong-scope vs. nothing-in-DOM are three different bugs that "step failed" can't distinguish.
const scopeEls = Array.from(
document.querySelectorAll('[data-onboarding-scope]'),
).map((e) => (e as HTMLElement).getAttribute('data-onboarding-scope'));
@@ -925,11 +664,7 @@ async function runOp(op: ACOp, ctx: RunContext): Promise<void> {
}
}
// Bring the target into view if any part of it is outside the viewport.
// Returns true if a scroll was actually triggered, false otherwise — the
// runtime uses this to decide whether to wait the smooth-scroll-settle
// beat. Scrolling-already-visible-element + 180ms wait would be pure
// added latency on every cursor move (~10s across the whole tour).
/** Returns true if a scroll was triggered; runtime uses this to skip the smooth-scroll-settle wait on already-visible targets (~10s saved across the tour). */
function scrollIntoViewIfNeeded(el: HTMLElement): boolean {
const r = el.getBoundingClientRect();
const vh = window.innerHeight;
@@ -943,45 +678,22 @@ function scrollIntoViewIfNeeded(el: HTMLElement): boolean {
try {
el.scrollIntoView({ block: 'center', inline: 'center', behavior: 'smooth' });
} catch {
// Older webview / jsdom — fall back to instant scroll.
try {
el.scrollIntoView();
} catch {
/* nothing to do — tracker will still try to pin once visible */
/* tracker will still try to pin once visible */
}
}
return true;
}
// True when the current URL is `#/dashboard/<id>` (a specific dashboard,
// where the toolbar with + / browser / etc. mounts). False on `#/`
// (dashboard list), `#/skills`, etc. HashRouter only — production app
// uses HashRouter so window.location.hash is the source of truth.
//
// Note: path is singular `/dashboard/`, not `/dashboards/` — that mismatch
// previously had the runtime thinking the user was always in a dashboard
// (since neither shape ever matched), which is why "Show me" from the
// Actions/Skills pages would barrel into a missing-+ button.
// HashRouter path is singular `/dashboard/`, not `/dashboards/`; mismatch previously had runtime always-in-dashboard.
function isInDashboardRoute(): boolean {
const h = window.location.hash || '';
return /^#\/dashboard\/[^/?#]+/.test(h);
}
// Ops the runtime prepends when a step requires being inside a dashboard
// but the user isn't. State-aware: reads the live DOM to skip sub-steps
// the user has already satisfied, so we never force a click that would
// undo the desired state (e.g. clicking the Dashboards section header
// when it's already expanded — which would collapse it).
//
// The two sub-conditions:
// 1. Sidebar Dashboards section is expanded (so rows are visible).
// Marked via data-expanded="true" / aria-expanded="true" on the
// ListItemButton in AppShell.
// 2. The user has clicked into a dashboard (route #/dashboard/<id>).
//
// If (1) is already met, we skip the section-click. If (2) is met, we
// don't run any of these ops at all — the caller already gates on
// isInDashboardRoute().
// State-aware: skips section-click when Dashboards is already expanded so we don't collapse it.
function buildOpenDashboardOps(): ACOp[] {
const sectionEl = document.querySelector<HTMLElement>(
'[data-onboarding="sidebar-dashboards"]',
@@ -1014,27 +726,13 @@ function buildOpenDashboardOps(): ACOp[] {
return ops;
}
// Set of targets that live INSIDE the sidebar's Customization collapse.
// If a step's move_to points at one of these and the section is closed,
// the user can't see (or click) the target — they'd have to click
// Customization first to expand it. The runtime checks this before each
// move_to and, if needed, walks the user through the expand-click first.
// Same pattern as buildOpenDashboardOps: state-aware, no redundant clicks.
const CUSTOMIZATION_AREA_TARGETS = new Set<string>([
'sidebar-actions',
'sidebar-skills',
'sidebar-modes',
]);
// Targets that live anywhere inside the sidebar (top-level nav rows,
// section headers, items revealed by an expanded section). If a step's
// move_to points at one of these and the WHOLE sidebar is collapsed
// (the AppShell ViewSidebar toggle hides the entire panel), the target
// element isn't in the DOM at all and waitForSelector would freeze the
// AC for a full 2.5s lost-target timeout before giving up.
//
// `sidebar-toggle` is deliberately excluded — it lives in the top bar
// and is the thing we click to expand. Recursing on it would loop.
// `sidebar-toggle` excluded: it lives in the top bar (we click it to expand). Recursing would loop.
const SIDEBAR_AREA_TARGETS = new Set<string>([
'sidebar-settings-button',
'sidebar-dashboards',
@@ -1046,52 +744,22 @@ const SIDEBAR_AREA_TARGETS = new Set<string>([
'dashboard-row-first',
]);
/**
* If the requested target lives inside the sidebar panel and the panel
* is currently collapsed (aria-expanded="false" on the top-bar
* ViewSidebar toggle), return ops to walk the user through clicking the
* toggle. Otherwise return null. Caller should runOps() the result
* before its own move_to.
*
* This guard MUST run before maybeBuildExpandCustomizationOps because
* the Customization header itself lives inside the collapsible panel —
* checking for an expanded Customization on a hidden panel would always
* read "not expanded" and queue an impossible click.
*/
/** MUST run before maybeBuildExpandCustomizationOps: Customization header is inside the collapsible panel, so expand-check on hidden panel queues an impossible click. */
function maybeBuildExpandSidebarOps(target: string): ACOp[] | null {
if (!SIDEBAR_AREA_TARGETS.has(target)) return null;
const toggle = document.querySelector<HTMLElement>(
'[data-onboarding="sidebar-toggle"]',
);
// aria-expanded reflects !sidebarCollapsed (true = sidebar visible).
// Missing / undefined means we couldn't find the toggle — assume
// visible and let waitForSelector handle the (unlikely) real failure
// so we don't gate on a missing marker.
// Missing toggle: assume visible and let waitForSelector handle the unlikely real failure.
const expanded =
toggle?.getAttribute('aria-expanded') === 'true' || toggle === null;
if (expanded) return null;
// Auto-expand: simulate-click the toggle. Previously we asked the
// user to click it themselves, which fell over in two ways: (1) if
// the AC's popup positioning glitched on collapsed-layout shift, the
// user saw the cursor freeze with no obvious instruction, and (2) the
// user shouldn't have to undo their own sidebar collapse to continue
// onboarding anyway. simulate:true fires the React onClick on the
// IconButton, the sidebar slides open, and the original move_to
// continues against the now-mounted target.
return [
{ kind: 'click', target: 'sidebar-toggle', simulate: true },
// Sidebar slide-in is ~200ms; the small delay lets the slide
// animation land before the next move_to reads rects.
{ kind: 'delay', ms: 260 },
];
}
/**
* If the requested target lives inside the Customization collapse and the
* section is currently closed, return ops to walk the user through
* expanding it. Otherwise return null. Caller should runOps() the result
* before its own move_to.
*/
function maybeBuildExpandCustomizationOps(target: string): ACOp[] | null {
if (!CUSTOMIZATION_AREA_TARGETS.has(target)) return null;
const header = document.querySelector<HTMLElement>(
@@ -1146,9 +814,6 @@ function waitForCondition(
if (timeoutMs && timeoutMs > 0) {
timer = window.setTimeout(() => {
// 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);
}
@@ -1,17 +1,4 @@
// Module-level signal for the cursor's logical position. Both the
// AgenticCursor component (which renders the arrow) and ACPopup /
// ACMultiChoice (which need to render relative to it) read from here.
//
// Performance contract: the cursor itself is driven by Framer Motion's
// imperative `controls.set`, which doesn't trigger React renders. This
// store exists ONLY so popups can follow during animation. Subscribers
// re-render on every notification, so naive frame-rate notifications
// would re-render the popup 60 times/sec — wasteful since popup
// position barely changes between sub-pixel cursor frames.
//
// We coalesce position writes to ~30fps via rAF and only notify when
// the cursor has moved more than COALESCE_PX. Visibility flips are
// flushed immediately (rare event, user-visible).
// Logical cursor position store; rAF-coalesced to ~30fps so popups don't re-render every frame.
import { useSyncExternalStore } from 'react';
@@ -25,9 +12,7 @@ let state: CursorPos = { x: 0, y: 0, visible: false };
let pendingState: CursorPos | null = null;
const listeners = new Set<() => void>();
// Sub-pixel cursor moves don't change popup position visibly, but they
// still trigger React renders. 1.5px is enough to feel smooth without
// re-rendering on every frame.
// 1.5px: smooth-feeling threshold that avoids per-sub-pixel React renders.
const COALESCE_PX = 1.5;
let rafScheduled = false;
@@ -44,8 +29,7 @@ export const cursorStore = {
set(next: Partial<CursorPos>) {
const merged = { ...(pendingState ?? state), ...next };
// Visibility transitions bypass coalescing — these are user-visible
// mounts/unmounts of popups, must flush immediately.
// Visibility transitions bypass coalescing (mounts/unmounts must flush immediately).
const visibilityChanged = merged.visible !== state.visible;
const dx = Math.abs(merged.x - state.x);
const dy = Math.abs(merged.y - state.y);
@@ -60,8 +44,7 @@ export const cursorStore = {
}
if (!significantMove) {
// Below threshold: update pending state silently. The next
// significant move will pick up the latest pending values.
// Below threshold: stash silently; next significant move will pick up these pending values.
pendingState = merged;
return;
}
@@ -1,12 +1,4 @@
// Tiny mitt-style event bus for onboarding-v2 advance conditions that
// don't have a natural Redux signal. Each emit site is a one-liner at the
// success path of a feature (browser:spawned at the end of spawnBrowser,
// settings:closed when the modal closes, etc).
//
// Why not Redux for everything: some events (browser navigated, app
// generation milestones) involve backend round-trips and the Redux state
// lags by a tick. Explicit emit at the success callsite is more
// deterministic than observing state.
// Mitt-style bus for onboarding-v2 advance conditions without a clean Redux signal.
export type OnboardingEvent =
| 'browser:spawned'
@@ -26,35 +18,18 @@ export type OnboardingEvent =
type Handler = (...args: unknown[]) => void;
// Replay window — see explanation on once() below. Tight on purpose so
// previous steps' emits can't accidentally satisfy current-step waits;
// the gating below is a stronger guarantee than the time window alone.
// Tight replay window; the gate below is the stronger guarantee against cross-step contamination.
const REPLAY_WINDOW_MS = 500;
class OnboardingBus {
private handlers = new Map<OnboardingEvent, Set<Handler>>();
// recentEmits stores the timestamp of the most recent emit per event.
// Used by once() to satisfy a subscription that races a synchronous
// emit (e.g. AC.click() → handleSend → emit happens BEFORE the next
// op's wait_user gets to register). Without this, the wait sits idle
// for its full timeout.
/** Most-recent-emit ts per event; lets once() satisfy a subscription racing a sync emit. */
private recentEmits = new Map<OnboardingEvent, number>();
// Monotonic gate id. Director bumps this whenever a new step starts;
// any once() subscriber that registers will only consider replays
// emitted after that bump. Solves the cross-step contamination case
// where step 6 emitted chat:message_sent ages ago and step 8's
// identical wait satisfies on the stale cached timestamp.
/** Monotonic gate bumped per new step; once() ignores emits older than the gate. */
private gateId = 0;
private gateTs = 0;
/**
* Bump the gate. Director calls this at the start of every new step
* (and at runStep cleanup). All recentEmits become invisible to
* subsequent once() subscribers — they only match emits that happen
* AFTER the bump. Also clears the recentEmits map outright as
* defense-in-depth — the gate alone would suffice but keeping a
* stale map around for hours is wasteful.
*/
/** Bump gate so subsequent once() subscribers only match emits after this point. */
resetReplayGate(): void {
this.gateId += 1;
this.gateTs = Date.now();
@@ -75,7 +50,6 @@ class OnboardingBus {
this.recentEmits.set(event, Date.now());
const set = this.handlers.get(event);
if (!set) return;
// Snapshot to avoid mutation during iteration.
[...set].forEach((h) => {
try {
h(...args);
@@ -86,10 +60,7 @@ class OnboardingBus {
}
once(event: OnboardingEvent, handler: Handler): () => void {
// Replay path: if this exact event was emitted within the last
// REPLAY_WINDOW_MS *AND* after the most recent gate bump, fire
// the handler now and don't register at all. The gate check is
// what prevents stale step-6 emits from satisfying step-8 waits.
// Replay: recent emit within window AND after the gate bump => fire now, skip registering.
const last = this.recentEmits.get(event);
if (
last !== undefined &&
@@ -115,9 +86,7 @@ class OnboardingBus {
export const onboardingBus = new OnboardingBus();
// Expose on window in dev for debugging — tests and the browser console
// can poke `window.__OPENSWARM_ONBOARDING_BUS__.emit('browser:spawned')`
// to advance steps without going through real product UI.
// Window-exposed for console debugging: __OPENSWARM_ONBOARDING_BUS__.emit('browser:spawned').
if (typeof window !== 'undefined') {
(window as any).__OPENSWARM_ONBOARDING_BUS__ = onboardingBus;
}
@@ -1,44 +1,27 @@
// Central registry of every data-onboarding (or data-select-type) string the
// onboarding v2 system targets. Step files import S.* — never inline literals
// — so a refactor that renames a selector breaks at type-check time and we
// can grep for usages.
//
// New keys added by v2 are commented; pre-existing keys (already wired in
// product code before v2) are noted with [existing].
// Central registry of data-onboarding / data-select-type selectors. Step files import S.*; never inline.
export const S = {
// [existing] sidebar / nav
sidebarSkills: 'sidebar-skills',
sidebarActions: 'sidebar-actions',
sidebarModes: 'sidebar-modes',
sidebarApps: 'sidebar-apps',
// new — sidebar
sidebarSettingsButton: 'sidebar-settings-button',
sidebarDashboards: 'sidebar-dashboards',
// The ViewSidebar icon in AppShell's top bar that hides/shows the
// whole sidebar. Wears aria-expanded={!sidebarCollapsed} so the
// runtime's expand-sidebar preflight can detect a collapsed state and
// walk the user through clicking it before targeting anything else
// in the sidebar.
/** Top-bar ViewSidebar toggle; aria-expanded drives the expand-sidebar preflight. */
sidebarToggle: 'sidebar-toggle',
// First row inside the expanded Dashboards section. The "click into a
// dashboard" hop targets this so the user lands inside a dashboard
// route (where the toolbar + and browser button actually exist).
/** First row in Dashboards section; "click into a dashboard" hop targets this. */
dashboardRowFirst: 'dashboard-row-first',
// [existing] dashboard toolbar
newAgentButton: 'new-agent-button',
browserButton: 'browser-button',
canvasControls: 'canvas-controls',
// new — dashboard toolbar
dashboardToolbarApps: 'dashboard-toolbar-apps',
// [existing] agent card
agentCard: 'agent-card', // matched via data-select-type as fallback
/** Matched via data-select-type as fallback. */
agentCard: 'agent-card',
// new — settings modal
settingsModelsTab: 'settings-models-tab',
settingsCloseButton: 'settings-close-button',
settingsProSection: 'settings-pro-section',
@@ -46,12 +29,10 @@ export const S = {
settingsApiKeys: 'settings-api-keys',
settingsRestartTour: 'settings-restart-tour',
// new — agent chat input
chatInput: 'chat-input',
chatSendButton: 'chat-send-button',
elementSelectionToggle: 'element-selection-toggle',
// new — actions / tools page
actionsRedditToggle: 'actions-reddit-toggle',
actionsRedditChevron: 'actions-reddit-chevron',
actionsSubredditsChevron: 'actions-subreddits-chevron',
@@ -59,51 +40,32 @@ export const S = {
actionsYoutubeToggle: 'actions-youtube-toggle',
actionsYoutubeChevron: 'actions-youtube-chevron',
// canvas controls toolbar — used by the inline tour-tip in step 5
// that flags fit-to-view / tidy / minimap once the user has multiple
// cards on the canvas.
canvasFitToView: 'canvas-fit-to-view',
canvasTidyLayout: 'canvas-tidy-layout',
canvasMinimapToggle: 'canvas-minimap-toggle',
// sidebar Customization section header — used by the runtime guard
// that auto-expands it before targeting Actions / Skills / Modes
// (which live inside the collapsed area).
/** Header for sidebar's Customization section; runtime auto-expands before targeting children. */
sidebarCustomization: 'sidebar-customization',
// new — skills page
skillItemPdf: 'skill-item-pdf',
skillInstallButton: 'skill-install-button',
skillBuilderFab: 'skill-builder-fab',
// new — apps / views page
appsNewButton: 'apps-new-button',
appCardLatest: 'app-card-latest',
// new — browser card
browserUrlBar: 'browser-url-bar',
} as const;
export type SelectorKey = (typeof S)[keyof typeof S];
// Selectors that may legitimately match multiple elements (one per agent
// card). For these we want the *newest* card — the one the user just
// spawned via the + button — not whichever agent happens to be earliest
// in DOM order. Without this scoping, step 6's "type into chat input"
// would hijack the existing "Open Swarm documentation" agent from step 5
// instead of the new orchestrator.
// Per-agent selectors resolve to the newest card so step 6 doesn't hijack step 5's agent.
const PER_AGENT_SELECTORS = new Set([
'chat-input',
'chat-send-button',
'element-selection-toggle',
]);
// Resolve a selector string to a live DOM node, falling back to data-select-type
// if data-onboarding doesn't match. Returns null if not found.
//
// Per-agent selectors get special treatment: querySelectorAll all matches
// and pick the one inside the LAST agent-card in DOM order (cards mount
// at the end as they're created, so the last is the newest). Single-match
// selectors are unchanged.
/** Resolve a selector to a DOM node; per-agent selectors pick the newest spawn. */
export function resolveSelector(target: string): HTMLElement | null {
const escaped = (window as any).CSS?.escape?.(target) ?? target;
@@ -114,11 +76,7 @@ export function resolveSelector(target: string): HTMLElement | null {
if (all.length === 0) return null;
if (all.length === 1) return all[0];
// Priority 1: the App Builder's AgentChat scope on /apps/. The
// App Builder mounts a regular AgentChat in the left pane —
// not wrapped in [data-select-type="agent-card"] — so without
// this explicit scope, step 8's chat-input would fall through
// to "last DOM match" and AC would type into nothing visible.
// Priority 1: App Builder's AgentChat scope. It mounts AgentChat without an agent-card wrapper.
const appBuilderScope = document.querySelector<HTMLElement>(
'[data-onboarding-scope="app-builder"]',
);
@@ -128,11 +86,7 @@ export function resolveSelector(target: string): HTMLElement | null {
);
if (scoped) return scoped;
}
// Priority 2: the dock toolbar's ChatInput, when open. This is the
// "draft agent" the user just opened by clicking + — higher
// priority than any existing agent-card so step 5/6's chat-input /
// send-button / element-selection-toggle ops route to the dock,
// not whichever agent-card is freshest in the DOM.
// Priority 2: dock toolbar's draft-ChatInput; outranks any existing agent-card.
const dockScope = document.querySelector<HTMLElement>(
'[data-onboarding-scope="dock"]',
);
@@ -143,9 +97,7 @@ export function resolveSelector(target: string): HTMLElement | null {
if (scoped) return scoped;
}
// Priority 2: the agent-card with the newest data-onboarding-spawn-ms
// (set from session.created_at). Used during/after the dock has been
// collapsed and a real agent card exists.
// Priority 3: agent-card with the newest data-onboarding-spawn-ms (after dock collapses).
const cards = document.querySelectorAll<HTMLElement>(
'[data-select-type="agent-card"]',
);
@@ -177,14 +129,7 @@ export function resolveSelector(target: string): HTMLElement | null {
return el;
}
// Wait for a selector to appear in the DOM. Resolves with the element, or
// rejects after timeoutMs. Used by acRuntime when a target is expected to
// mount asynchronously (e.g. settings modal, just-spawned card).
//
// Default bumped to 15s because under heavy main-thread load (many agents
// streaming, App Builder /apps/new mounting AgentChat with its own model
// probe + fetches), 8s was sometimes not enough and AC would abort into
// the recovery popup just before the target finally rendered.
/** Resolve when target mounts; 15s default to ride out heavy main-thread load on /apps/new. */
export function waitForSelector(
target: string,
timeoutMs = 15000,
@@ -205,8 +150,7 @@ export function waitForSelector(
}
});
obs.observe(document.body, { childList: true, subtree: true, attributes: true });
// Also poll as a safety net — MutationObserver misses nothing in practice
// but the timeout path needs a way to fire even if the DOM is quiet.
// Poll as a safety net so the timeout path fires even if the DOM is quiet.
setTimeout(() => {
const el = resolveSelector(target);
if (el) {
@@ -1,7 +1,4 @@
// Shared skipIf predicates. Each returns true when the corresponding step
// is already-done in current Redux state — used to pre-mark completed
// milestones for upgrading users and to short-circuit "Show me" if the
// user already did the thing.
// skipIf predicates: true => step is already-done in current Redux state.
import type { RootState } from '@/shared/state/store';
import {
@@ -11,9 +8,7 @@ import {
export function hasModelConnected(s: RootState): boolean {
const d = s.settings.data as any;
if (!d) return false;
// Path 1: OpenSwarm Pro cloud bearer.
if (d.connection_mode === 'openswarm-pro' && d.openswarm_bearer_token) return true;
// Path 2: first-party API keys typed into Settings → Models.
if (
d.anthropic_api_key ||
d.openai_api_key ||
@@ -22,35 +17,22 @@ export function hasModelConnected(s: RootState): boolean {
) {
return true;
}
// Path 3: custom OpenAI-compatible providers (LM Studio, Ollama, etc.).
// Match the validity rule the Settings page uses to render the provider
// row: name + base_url present. The api_key field is intentionally
// optional — local OpenAI-compatible servers don't require one.
// Custom OpenAI-compatible providers; api_key optional for local servers.
const customs = (d.custom_providers || []) as any[];
if (customs.some((cp) => cp?.name?.trim() && cp?.base_url?.trim())) {
return true;
}
// Path 4: external OAuth subscriptions (Claude Max, ChatGPT, etc.). The
// tokens live in 9Router-managed storage and are surfaced to the frontend
// only via the subscriptionsSlice mirror of /agents/subscriptions/status.
if (hasAnyActiveSubscription(s)) return true;
return false;
}
export function hasAnyToolEnabled(s: RootState): boolean {
const items = s.tools?.items ?? {};
// Match the Switch's read in Tools.tsx: `tool.enabled !== false`. Tools
// installed before the `enabled` field existed have it as undefined,
// which the Switch treats as "on" — so we should too. Otherwise step 2
// never auto-skips for users who already have integrations installed.
// Match Tools.tsx Switch read: enabled !== false; pre-field tools treat undefined as on.
return Object.values(items).some((t: any) => t?.enabled !== false);
}
// True when a YouTube-shaped tool is currently enabled. Used by step 2's
// wait-for-toggle so the wait only resolves when YouTube is actually ON,
// regardless of how many times the user toggles. Step 2 uses YouTube to
// match the rest of the tour (step 3 prompts for a YouTube video summary,
// so enabling YouTube here is a coherent throughline).
/** True when a YouTube-shaped tool is on; step 2 waits on this so toggle-flapping stays in sync. */
export function isYoutubeEnabled(s: RootState): boolean {
const items = s.tools?.items ?? {};
return Object.values(items).some((t: any) => {
@@ -72,11 +54,7 @@ export function hasAnySkillInstalled(s: RootState): boolean {
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.
/** True if PDF skill installed (id/name/command); step 7 uses this so other skills don't auto-skip. */
export function hasPdfSkillInstalled(s: RootState): boolean {
const items = s.skills?.items as any;
const list: any[] = Array.isArray(items) ? items : Object.values(items ?? {});
@@ -88,9 +66,7 @@ export function hasPdfSkillInstalled(s: RootState): boolean {
});
}
// 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.
/** True if a browser card exists; step 4 auto-skips the open-a-browser walkthrough. */
export function hasAnyBrowserSpawned(s: RootState): boolean {
const cards = (s as any).dashboardLayout?.browserCards ?? {};
return Object.keys(cards).length > 0;
@@ -10,10 +10,7 @@ export const step02: OnboardingStep = {
description: 'Allow agents to work across your apps.',
videoSrc: './onboarding-videos/v2/02.mp4',
videoDurationLabel: '0:24',
// 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.
// Narrowed to YouTube so users with other tools still get walked; step 3 needs YouTube on.
skipIf: isYoutubeEnabled,
ops: [
{ kind: 'move_to', target: S.sidebarActions },
@@ -22,12 +19,7 @@ export const step02: OnboardingStep = {
kind: 'wait_user',
condition: { kind: 'click_target', target: S.sidebarActions },
},
// YouTube toggle. Picked YouTube here (instead of Reddit) so the
// tour has a consistent throughline — step 3 launches an Agent that
// summarizes a YouTube video, so enabling the YouTube integration
// here directly powers the next step. Wait on REDUX STATE (YouTube
// enabled), not a single click — if the user toggles off then back
// on, AC stays in sync.
// YouTube on the throughline; step 3 needs it. Waits on Redux state, not click, so toggling stays synced.
{ kind: 'move_to', target: S.actionsYoutubeToggle },
{ kind: 'popup', text: 'Flip YouTube on.' },
{
@@ -39,15 +31,12 @@ export const step02: OnboardingStep = {
},
timeoutMs: 90000,
},
// Expand the YouTube row to reveal its actions list.
{ kind: 'move_to', target: S.actionsYoutubeChevron },
{ kind: 'popup', text: 'Tap to peek inside.' },
{
kind: 'wait_user',
condition: { kind: 'click_target', target: S.actionsYoutubeChevron },
},
// Hover the permission toggle for the first listed action and
// explain what it controls. No click required from the user.
{ kind: 'move_to', target: S.actionsPermissionToggle },
{
kind: 'popup',
@@ -2,13 +2,7 @@ import type { OnboardingStep } from './types';
import { S } from '../selectors';
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.
// Primary: YouTube summary (needs MCP from step 2). Fallback uses built-in web tools (no MCP).
const YOUTUBE_PROMPT =
'What is this youtube video about: https://youtu.be/_NKj8KQMY-k?si=rEk4KO2bOpa5Vo0z. Do not use browser agents.';
const FALLBACK_PROMPT =
@@ -31,22 +25,13 @@ export const step03: OnboardingStep = {
kind: 'wait_user',
condition: { kind: 'click_target', target: S.newAgentButton },
},
// Chat input mounts asynchronously after + is clicked. waitForSelector
// inside the runtime handles the small delay before type_into runs.
{
kind: 'type_into',
target: S.chatInput,
// 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).
// YouTube prompt bans browser agents (MCP handles it); fallback uses web tools by design.
text: (state) => (isYoutubeEnabled(state) ? YOUTUBE_PROMPT : FALLBACK_PROMPT),
speedMs: 12,
},
// Auto-send the prompt — same pattern as steps 5/6/8. Without this,
// the user lands on a typed-but-unsent prompt and has to hit send
// themselves, which is awkward and out-of-line with the other steps.
{ kind: 'move_to', target: S.chatSendButton },
{ kind: 'click', target: S.chatSendButton, simulate: true },
{
@@ -11,15 +11,8 @@ 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.
// Auto-skip if a browser card already exists.
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
// header (which only toggles the sidebar list) and never actually
// navigated the user into a dashboard.
requiresDashboard: true,
ops: [
{ kind: 'move_to', target: S.browserButton },
@@ -18,32 +18,17 @@ export const step05: OnboardingStep = {
kind: 'wait_user',
condition: { kind: 'click_target', target: S.newAgentButton },
},
// Offset nudge: cursor SVG is asymmetric (tip top-left, body
// extends ~8px right and ~10px down). Default rect-center pinning
// puts the cursor BODY over the adjacent paperclip "Attach file"
// button instead of this icon. Shifting the tip up-and-left by
// (-10, -10) puts the body's visual center over this icon's
// center, where it belongs.
// Offset (-10,-10): cursor SVG is asymmetric so default-center pins on the adjacent paperclip.
{ kind: 'move_to', target: S.elementSelectionToggle, offset: { x: -10, y: -10 } },
{ kind: 'popup', text: 'Tap here to plug a browser into this chat.' },
{
kind: 'wait_user',
condition: { kind: 'click_target', target: S.elementSelectionToggle },
},
// Auto-fit the canvas before the drag-select demo so BOTH the new
// chat card AND the browser card are visible together. Without
// this, Dashboard's autoFocusSessionId pans the camera to center
// the freshly-created chat, which often clips the browser card half
// off-screen — and the user gets confused trying to drag-select
// something they can barely see. simulate:true clicks the
// fit-to-view toolbar button programmatically; user sees the
// camera resnap to a clean view in ~300ms before the drag demo.
// Fit-to-view so chat + browser card are both visible for drag-select; autoFocusSessionId otherwise clips.
{ kind: 'move_to', target: S.canvasFitToView },
{ kind: 'click', target: S.canvasFitToView, simulate: true },
{ kind: 'delay', ms: 350 },
// AC demonstrates the drag-select on the browser card, then asks the
// user to do the same gesture for real (the actual product wires up
// the selection during a real mouse drag).
{ kind: 'drag_select', target: 'browser-card' },
{
kind: 'popup',
@@ -63,11 +48,7 @@ export const step05: OnboardingStep = {
},
{ kind: 'move_to', target: S.chatSendButton },
{ kind: 'click', target: S.chatSendButton, simulate: true },
// Quick canvas-controls tour, NOT a real step. The user now has a
// browser + chat on the canvas, which is the first time those
// toolbar buttons (fit-to-view, tidy, minimap) actually have
// anything meaningful to do. AC just hovers each one and drops a
// single short popup; no waits, no clicks expected from the user.
// Inline canvas-controls tour (hover + popup, no waits/clicks expected).
{ kind: 'move_to', target: S.canvasFitToView },
{ kind: 'popup', text: 'Heads up! This snaps everything back into view.' },
{ kind: 'delay', ms: 1800 },
@@ -10,13 +10,7 @@ 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.
// Reuses step 3's chat as the orchestratee; step 6 always has one available by now.
ops: [
{
kind: 'popup',
@@ -28,18 +22,14 @@ export const step06: OnboardingStep = {
kind: 'wait_user',
condition: { kind: 'click_target', target: S.newAgentButton },
},
// See step05 — same nudge so the cursor's visual body center sits
// over the select-mode icon, not the adjacent paperclip.
// See step05 (cursor body offset).
{ kind: 'move_to', target: S.elementSelectionToggle, offset: { x: -10, y: -10 } },
{ kind: 'popup', text: 'Tap here to hook in the older chat.' },
{
kind: 'wait_user',
condition: { kind: 'click_target', target: S.elementSelectionToggle },
},
// Same auto-fit as step 5: the new orchestrator chat triggers
// Dashboard's autoFocusSessionId, which often pushes the older
// chat off-screen. Click fit-to-view first so both cards are
// visible together for the drag-select demo.
// Fit-to-view (same reason as step 5).
{ kind: 'move_to', target: S.canvasFitToView },
{ kind: 'click', target: S.canvasFitToView, simulate: true },
{ kind: 'delay', ms: 350 },
@@ -50,28 +40,21 @@ export const step06: OnboardingStep = {
},
{
kind: 'wait_user',
// Reuses agent:attached_to_browser; backend emits it for any element-selection attach.
condition: { kind: 'event_bus', event: 'agent:attached_to_browser' },
// Reuses the same attached event as step 5 for now — backend emits
// it for any element-selection attachment regardless of element type.
timeoutMs: 90000,
},
{ kind: 'move_to', target: S.chatInput },
{
kind: 'type_into',
target: S.chatInput,
// 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.
// Source-agnostic; works for either step 3 prompt.
text: 'Turn what it dug up into a PDF report and save it to my downloads.',
speedMs: 12,
},
{ kind: 'move_to', target: S.chatSendButton },
{ kind: 'click', target: S.chatSendButton, simulate: true },
// Wait for the user's message to actually go out — short wait, just
// to confirm the orchestration kicked off. Don't wait for the agent
// to fully finish: orchestrators legitimately run for minutes,
// sub-agents loop while doing real work, and trapping the user
// in step 6 until everything settles is the worst possible UX.
// Confirm message went out; don't wait for the orchestrator to finish (legitimately runs minutes).
{
kind: 'wait_user',
condition: { kind: 'event_bus', event: 'chat:message_sent' },
@@ -10,10 +10,7 @@ export const step07: OnboardingStep = {
description: 'Teach agents how to handle specific tasks.',
videoSrc: './onboarding-videos/v2/07.mp4',
videoDurationLabel: '0:24',
// 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).
// Narrowed to PDF so other-skill users still walk through this demo.
skipIf: hasPdfSkillInstalled,
ops: [
{ kind: 'move_to', target: S.sidebarSkills },
@@ -22,29 +22,7 @@ export const step08: OnboardingStep = {
kind: 'wait_user',
condition: { kind: 'click_target', target: S.appsNewButton },
},
// After clicking +, the /apps/new route mounts ViewEditor which
// asynchronously renders AgentChat in the left pane. Three failure
// modes we have to defend against:
// 1. Cold start can take well over 8 s before AgentChat mounts
// inside the app-builder scope wrapper — vite warm-up + session
// creation + three parallel onboarding sessions racing the
// backend's probe-model queue stack up under load.
// 2. The /apps/new route briefly mounts → unmounts → remounts
// ViewEditor (runtime/start → runtime/stop → runtime/start
// visible in the dev log when the React Strict-Mode double-
// effect collides with the route transition). The scope
// wrapper disappears during the unmount, and wait_for_dom
// polling can land in that gap.
// 3. AgentChat's hardcoded `disabled={false}` means the
// contenteditable attribute is always "true" when the input
// mounts — so we don't need to gate on it (and gating on a
// stringly-serialized React attribute introduces a brittle
// dependency on React's attribute reflection).
//
// Fix: wait for the SCOPED chat-input. 30 s timeout swallows any
// reasonable cold start including the mount-unmount-remount cycle.
// An extra 350 ms `delay` lets the post-mount React commit settle
// (refs, event handlers, focus shims) before we move the cursor.
// Wait for the SCOPED chat-input; survives cold-starts and the StrictMode mount/unmount/remount cycle.
{
kind: 'popup',
text: 'Loading the App Builder...',
@@ -55,9 +33,6 @@ export const step08: OnboardingStep = {
timeoutMs: 60000,
},
{ kind: 'delay', ms: 350 },
// 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',
@@ -65,20 +40,11 @@ export const step08: OnboardingStep = {
text: 'Make me a pdf previewer app',
speedMs: 12,
},
// AC auto-clicks send per spec ("the AC should auto send this").
// Tiny pause first to let onInput's draft-state commit land — the
// send button is disabled-while-empty, so clicking before React's
// next commit sometimes lands on the stale-disabled button.
// 120ms pause lets onInput's draft-state commit before clicking; send-button is disabled-while-empty.
{ kind: 'delay', ms: 120 },
{ kind: 'move_to', target: S.chatSendButton },
{ kind: 'click', target: S.chatSendButton, simulate: true },
// Wait only for chat:message_sent (the prompt actually going out).
// Don't wait for app:generation_done — the App Builder agent can
// take any of several legitimate paths: save as a standalone HTML
// to ~/Downloads and open in the system browser, save as an
// OpenSwarm Output, or skip saving entirely. We can't reliably
// detect every completion shape, and trapping the user in step 8
// until a specific one happens is the worst possible UX.
// chat:message_sent only; app:generation_done has too many legitimate completion shapes.
{
kind: 'wait_user',
condition: { kind: 'event_bus', event: 'chat:message_sent' },
@@ -1,20 +1,14 @@
// Onboarding v2 step / op / advance-condition schema.
//
// Steps are pure data: a sequence of ACOps (cursor primitives) interleaved
// with wait_user gates that block until an AdvanceCondition fires. The
// runtime in ../ac/acRuntime.ts is the only place that knows how to
// execute these; step files import only this module.
// Onboarding-v2 step/op/advance-condition schema; ../ac/acRuntime.ts is the only executor.
import type { RootState } from '@/shared/state/store';
export type Selector = string; // matches data-onboarding="<v>" or data-select-type="<v>"
/** Matches data-onboarding="<v>" or data-select-type="<v>". */
export type Selector = string;
export type ACMultiChoiceOption = {
id: string;
label: string;
// Optional branching — if present, picking this option queues additional
// ops to run before the rest of the step's ops continue. Lets one step
// diverge based on user choice without splitting into N steps.
/** If set, queue extra ops on selection so one step can branch without splitting. */
thenOps?: ACOp[];
};
@@ -26,10 +20,7 @@ export type ACOp =
| {
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.
/** Static string or function evaluated once at op-execution; not reactive to subsequent state. */
text: string | ((state: RootState) => string);
speedMs?: number;
}
@@ -37,11 +28,7 @@ export type ACOp =
| { 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).
/** Poll a raw CSS selector until it mounts, up to timeoutMs (step 8 uses for App Builder chat-input). */
| { kind: 'wait_for_dom'; css: string; timeoutMs?: number }
| { kind: 'outro' };
@@ -60,22 +47,18 @@ export interface StepDependency {
export interface OnboardingStep {
id: string;
stage: StepStage;
index: number; // 1..N (currently 1..8)
/** 1..N (currently 1..8). */
index: number;
title: string;
description: string;
videoSrc?: string;
videoDurationLabel?: string; // e.g. "0:24" — shown in the panel preview chip
/** Shown in the panel preview chip, e.g. "0:24". */
videoDurationLabel?: string;
ops: ACOp[];
dependsOn?: StepDependency[];
// skipIf is evaluated on launch (and on each Show me click) to mark a step
// already-done without running its flow. Lets existing v1.0.29 users
// upgrade and have already-completed milestones pre-checked.
/** Mark a step already-done at launch / Show me click without running its flow. */
skipIf?: (state: RootState) => boolean;
// True if the step's ops target dashboard-toolbar elements (+, browser,
// chat input, send, element-selection toggle, apps button). The runtime
// auto-prepends a "click into a dashboard" hop when the user isn't
// already on a #/dashboards/:id route. Without this, every "Show me"
// from the actions/skills/apps pages would hang on a missing target.
/** True when ops target dashboard-toolbar elements; runtime auto-prepends a click-into-dashboard hop. */
requiresDashboard?: boolean;
}
@@ -1,12 +1,4 @@
// Onboarding v2 telemetry — wraps the existing report() surface so all
// events land under surface='onboarding_v2' (separate from the legacy
// onboarding/walkthrough rows so dashboards stay clean during transition).
//
// Standard properties on every report:
// step_id — current step (or 'panel' / 'roadmap' for non-step events)
// stage — 'get_started' | 'customize'
// ms_since_step — time since the active step started (panel "Show me" click)
// Plus whatever the caller passes in.
// Wraps report() so all onboarding-v2 events land under surface='onboarding_v2'.
import { report as _report } from '@/shared/serviceClient';
+3 -25
View File
@@ -1,25 +1,8 @@
// Bayer-dithering pixel-blast background. Same shader as the inline
// splash in the webapp_template's index.html and the React component at
// `webapp_template/frontend/src/components/PixelBlast.tsx`, so all three
// "cold start" phases of an App preview look identical:
//
// 1. Desktop placeholder before Vite has bound (`<InstallPlaceholder>`
// in ViewEditor, before frontend_url arrives over the runtime WS).
// 2. Inline `<canvas>` in `index.html`, painted before any JS bundle
// loads.
// 3. React-rendered placeholder in `pages/index.tsx`, replaced when
// the agent overwrites that file.
//
// Plain WebGL2, no three.js / postprocessing.
// Bayer-dithering pixel-blast background; same shader as webapp_template splash so cold-start phases match.
import React, { useEffect, useRef } from 'react';
// Module-level epoch so a fresh component mount picks up where the
// previous mount left off in the noise field. Without this, every time
// the user clicked away from a dashboard card and back the animation
// reset to t=0, which read as a jarring "snap" instead of an ambient
// loop. Captured once at module load; all instances of the component
// share it.
// Module-level epoch so remounts pick up where the previous mount left off in the noise loop.
const PIXEL_BLAST_EPOCH = performance.now();
interface PixelBlastProps {
@@ -85,12 +68,7 @@ float fbm2(vec2 uv, float t){
return sum * 0.5 + 0.5;
}
void main(){
// Offset by a non-zero constant so y=0 and x=0 don't land on FBM
// singularities. Without this the noise function returns the same
// value along the screen center axes, which the Bayer threshold
// accents into a visible horizontal (or vertical) bright stripe.
// 137.5 is the golden-ratio angle in degrees, a classic
// "no-aliasing" constant for shader UVs.
// 137.5 (golden-ratio angle) offsets off the FBM singularities so the center axes don't bright-stripe.
vec2 fragCoord = gl_FragCoord.xy - uResolution * 0.5 + vec2(137.5, 137.5);
float aspectRatio = uResolution.x / uResolution.y;
float cellPixelSize = 8.0 * uPixelSize;
+10 -26
View File
@@ -15,15 +15,14 @@ import {
CheckoutSource,
} from '@/shared/subscription/checkout';
// Pricing table. Keep in sync with the Stripe price IDs configured on
// api.openswarm.com. Annual is shown as the monthly-equivalent rate with a
// "billed annually" subtitle, mirroring Anthropic's pricing page copy.
// Pricing table; keep in sync with Stripe price IDs on api.openswarm.com.
interface PlanDef {
id: OpenSwarmPlan;
name: string;
tagline: string;
monthly: number;
annual: number; // billed monthly equivalent when paid annually
/** Monthly-equivalent when billed annually. */
annual: number;
featuresHeader: string;
features: string[];
recommended?: boolean;
@@ -78,15 +77,11 @@ interface PlanPickerProps {
defaultPlan?: OpenSwarmPlan;
defaultInterval?: BillingInterval;
compact?: boolean;
// The user's current or most-recent tier, if any. Drives the CTA text on
// each card: same-tier → "Resubscribe", higher-tier → "Upgrade",
// lower-tier → "Downgrade". When undefined the user is a new customer and
// every card says "Subscribe".
/** User's current tier; drives Resubscribe/Upgrade/Downgrade CTA copy. */
currentPlan?: OpenSwarmPlan;
onSubscribed?: (plan: OpenSwarmPlan) => void;
}
// Tier ordering for upgrade/downgrade comparison.
const TIER_RANK: Record<OpenSwarmPlan, number> = {
pro: 1,
pro_plus: 2,
@@ -133,15 +128,13 @@ const PlanPicker: React.FC<PlanPickerProps> = ({
report('subscription', 'billing_interval_toggled', { source, interval: next });
};
// Typography scale — scaled down in compact mode (MessageBubble modal) but
// still keeping the same visual hierarchy (plan name ≈ price size).
// Typography scale: smaller in compact mode (modal), same hierarchy.
const sz = compact
? { name: '1.35rem', price: '2rem', tagline: '0.78rem', features: '0.78rem', cta: '0.82rem', micro: '0.7rem', sub: '0.68rem', hdr: '0.72rem', suffix: '0.78rem' }
: { name: '1.75rem', price: '2.4rem', tagline: '0.85rem', features: '0.85rem', cta: '0.88rem', micro: '0.72rem', sub: '0.72rem', hdr: '0.78rem', suffix: '0.85rem' };
return (
<Box sx={{ width: '100%' }}>
{/* Billing interval toggle — annual selected by default */}
<Box sx={{ display: 'flex', justifyContent: 'center', mb: compact ? 2 : 2.5 }}>
<ToggleButtonGroup
value={interval}
@@ -167,11 +160,10 @@ const PlanPicker: React.FC<PlanPickerProps> = ({
}}
>
<ToggleButton value="monthly">Monthly</ToggleButton>
<ToggleButton value="annual">Annual · save 15%</ToggleButton>
<ToggleButton value="annual">Annual, save 15%</ToggleButton>
</ToggleButtonGroup>
</Box>
{/* Plan cards — grid in regular mode, stacked column in compact */}
<Box
sx={{
display: 'grid',
@@ -200,14 +192,13 @@ const PlanPicker: React.FC<PlanPickerProps> = ({
transition: 'border-color 0.15s, background 0.15s',
}}
>
{/* Name + "your plan" indicator */}
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.8, mb: 0.4 }}>
<Typography sx={{ fontSize: sz.name, fontWeight: 700, color: c.text.primary, lineHeight: 1.1 }}>
{plan.name}
</Typography>
{isDefault && (
<Typography sx={{ fontSize: sz.micro, color: c.text.muted, fontWeight: 500 }}>
· your plan
your plan
</Typography>
)}
</Box>
@@ -216,7 +207,6 @@ const PlanPicker: React.FC<PlanPickerProps> = ({
{plan.tagline}
</Typography>
{/* Price row — big number + /mo */}
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.5, mb: 0.2 }}>
<Typography sx={{ fontSize: sz.price, fontWeight: 700, color: c.text.primary, lineHeight: 1 }}>
${price}
@@ -229,9 +219,6 @@ const PlanPicker: React.FC<PlanPickerProps> = ({
{interval === 'annual' ? 'billed annually' : 'billed monthly'}
</Typography>
{/* CTA moved ABOVE features — Anthropic pattern. Filled accent
for the recommended tier, outlined for the others; no
separate RECOMMENDED badge needed. */}
<Button
onClick={() => handleSubscribe(plan.id)}
disabled={pending !== null}
@@ -251,15 +238,13 @@ const PlanPicker: React.FC<PlanPickerProps> = ({
{isPending ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.7 }}>
<CircularProgress size={14} sx={{ color: 'inherit' }} />
<span>Opening</span>
<span>Opening...</span>
</Box>
) : (
ctaLabel(plan.id, plan.name, currentPlan)
)}
</Button>
{/* Microcopy row under every CTA — matches Anthropic's
reassurance-under-the-big-button pattern. */}
<Typography
sx={{
fontSize: sz.micro,
@@ -270,13 +255,12 @@ const PlanPicker: React.FC<PlanPickerProps> = ({
}}
>
{isRecommended
? 'Most popular · cancel anytime'
? 'Most popular, cancel anytime'
: plan.id === 'ultra'
? 'No commitment · cancel anytime'
? 'No commitment, cancel anytime'
: 'Cancel anytime'}
</Typography>
{/* Divider + cumulative features — "Everything in Pro, plus:" */}
<Box
sx={{
borderTop: `1px solid ${c.border.subtle}`,
@@ -66,7 +66,7 @@ const RichPromptEditor: React.FC<RichPromptEditorProps> = ({
const isLabelFloating = focused || hasContent;
// Sync external value editor on mount / when value changes externally
// Sync external value to editor on mount / when value changes externally.
const lastEmittedRef = useRef<string | null>(null);
useEffect(() => {
const editor = editorRef.current;
+4 -30
View File
@@ -1,26 +1,4 @@
// Mandatory sign-in gate. Two paths to identity:
//
// 1. Continue with Google → cloud OAuth handoff (existing).
// Opens https://api.openswarm.com/api/auth/google/start in the OS
// browser; the cloud's bearer-handoff page POSTs the bearer back to
// this desktop's local /api/auth/signin-activate. settings.user_id
// flips non-null and the gate self-dismisses (SignInGateLoader's
// poll picks up the change within ~2s).
//
// 2. Email magic link. Two-stage:
// - Stage 1: user enters their email, we POST /api/auth/email/start.
// Cloud mints a 6-digit code, stores its hash, sends it via Resend.
// - Stage 2: user pastes the code, we POST /api/auth/email/verify.
// On success the cloud upserts the users row, mints a bearer with
// source='email', returns the same handoff shape as Google, and
// the desktop's existing signin-activate path takes it from there.
//
// No password. Each sign-in (first or returning) requires reading a fresh
// code from the inbox. Slightly more friction than a stored-password fast
// path, but it eliminates the "someone with just my email signs in as me"
// worry and means there's no credential to store, leak, or rotate.
//
// No "Skip for now" — sign-in is mandatory.
// Mandatory sign-in gate; Google OAuth handoff or email magic-link (6-digit code per sign-in).
import React, { useState } from 'react';
import {
@@ -82,9 +60,7 @@ export default function SignInGate(): JSX.Element {
}
setBusy(true);
// "Failed to fetch" or 404 here means the cloud build doesn't have the
// magic-link routes yet. Surface a single friendly hint instead of the
// raw network error.
// 404/"Failed to fetch" = cloud build lacks magic-link routes; surface a friendly hint.
const EMAIL_UNAVAILABLE_MSG =
"Email sign-in isn't available on this build yet. Please use Continue with Google for now, or update OpenSwarm.";
@@ -145,8 +121,7 @@ export default function SignInGate(): JSX.Element {
}
const data = (await res.json()) as { bearer?: string; user_id?: string; user_email?: string };
if (!data.bearer) throw new Error('Server did not return a bearer.');
// Hand the bearer to the local backend the same way Google's
// handoff page does, so the rest of the app converges identically.
// Hand bearer to local backend like Google's handoff page so the app converges identically.
const activate = await fetch(`${API_BASE}/auth/signin-activate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -160,8 +135,7 @@ export default function SignInGate(): JSX.Element {
const text = await activate.text().catch(() => '');
throw new Error(text || `Local activate failed (${activate.status})`);
}
// SignInGateLoader's polling picks up the new user_id within 2s
// and unmounts this gate. Nothing else to do.
// SignInGateLoader's 2s poll picks up new user_id and unmounts the gate.
} catch (err) {
setErrMsg((err as Error).message || 'Verification failed.');
} finally {
@@ -139,7 +139,6 @@ export function useDomElementSelector(): DomSelectorState {
}, [ctx?.selectedElements]);
const handleMouseMove = useCallback((e: MouseEvent) => {
// If we're drawing a drag rectangle, update it instead of hover overlay
if (dragOriginRef.current) {
const origin = dragOriginRef.current;
const dx = e.clientX - origin.x;
@@ -253,15 +252,7 @@ export function useDomElementSelector(): DomSelectorState {
preDragFocusRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
dragOriginRef.current = { x: e.clientX, y: e.clientY };
isDraggingRef.current = false;
// Make webviews/iframes transparent to mouse events for the duration of
// a potential drag. Without this, dragging the selection rect across a
// browser card freezes the rect at the webview's entry edge — the
// <webview> hit-tests the cursor at the OS level and steals mousemove
// events from the document listener until the cursor exits the other
// side. Reuses the existing CSS rule installed by useDashboardSelection
// ("body.dashboard-marquee-active webview, ... { pointer-events: none }").
// Added on mousedown (not on first drag-threshold cross) so the cursor
// is already passing through if the drag begins inside a webview.
// Make webviews pointer-transparent during drag; the OS-level hit-test would otherwise steal mousemove.
document.body.classList.add('dashboard-marquee-active');
}, []);
@@ -375,8 +366,7 @@ export function useDomElementSelector(): DomSelectorState {
dragBoundsRef.current = null;
isDraggingRef.current = false;
preDragFocusRef.current = null;
// Defensive if select mode flips off mid-drag, drop the class so
// webviews regain interactivity.
// Defensive: if select mode flips off mid-drag, drop the class so webviews regain interactivity.
document.body.classList.remove('dashboard-marquee-active');
};
}, [ctx?.selectMode, handleMouseMove, handleMouseDown, handleMouseUp, handleClick]);