mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-20 16:47:40 +02:00
defluff (frontend + backend): strip em-dashes + shorten docstrings + drop dead UI files (cosmetic only, no schedule code)
This commit is contained in:
@@ -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'
|
||||
|
||||
@@ -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,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();
|
||||
|
||||
|
||||
@@ -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',
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]);
|
||||
|
||||
Reference in New Issue
Block a user