Files
openswarm/frontend/src/app/components/Onboarding/ac/acRuntime.ts
T

688 lines
25 KiB
TypeScript

// 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).
import type { Store } from '@reduxjs/toolkit';
import type { RootState } from '@/shared/state/store';
import {
recordMultiChoice,
markStepCompleted,
clearJustCompleted,
setRunning,
setCurrentStep,
} 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 } from '../selectors';
import {
spawnGlowRect,
clickRipple,
animateDragSelect,
sleep,
} from './ACGestures';
import { typeInto } from './ACTypewriter';
import type {
ACOp,
AdvanceCondition,
OnboardingStep,
} from '../steps/types';
import type { AgenticCursorHandle } from './AgenticCursor';
interface RunContext {
ac: AgenticCursorHandle;
store: Store<RootState>;
spawnPoint: { x: number; y: number };
accentColor: string;
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 };
}
export interface RunStepArgs {
step: OnboardingStep;
spawnPoint: { x: number; y: number };
ac: AgenticCursorHandle;
store: Store<RootState>;
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;
}
export async function runStep(args: RunStepArgs): Promise<void> {
const { step, spawnPoint, ac, store, accentColor, signal, findStep } = args;
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.
onboardingBus.resetReplayGate();
report('step_started', { step_id: step.id, stage: step.stage });
const highlightCleanup: { current: (() => void) | null } = { current: null };
const ctx: RunContext = {
ac,
store,
spawnPoint,
accentColor,
signal,
silent: false,
stepId: step.id,
findStep,
highlightCleanup,
};
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.
if (step.requiresDashboard && !isInDashboardRoute()) {
await runOps(buildOpenDashboardOps(), ctx);
}
if (step.dependsOn?.length) {
for (const dep of step.dependsOn) {
if (args.isDependencySatisfied?.(dep.stepId)) continue;
const depStep = findStep(dep.stepId);
if (!depStep) continue;
if (dep.reopen === 'walk_again') {
report('dependency_walk', { step_id: step.id, dep_id: dep.stepId });
await runOps(depStep.ops, { ...ctx, silent: true, stepId: depStep.id });
}
}
}
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) {
store.dispatch(clearJustCompleted());
}
}, 950);
} catch (err) {
const isAbort =
(err as DOMException)?.name === 'AbortError' || signal.aborted;
const msg = (err as Error)?.message ?? String(err);
const isSelectorTimeout = /^waitForSelector:/.test(msg);
if (isAbort) {
report('step_aborted', { step_id: step.id });
} else if (isSelectorTimeout) {
report('step_selector_timeout', { step_id: step.id, error: msg });
} else {
console.error('[onboarding] step failed', step.id, err);
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.
store.dispatch(setRunning(false));
try {
ac.hidePopup();
ac.stopTracking();
if (highlightCleanup.current) {
highlightCleanup.current();
highlightCleanup.current = null;
}
const showMessage = !signal.reason || signal.reason !== 'user-cancel';
if (showMessage) {
ac.showPopup(
"No worries — feel free to explore. Tap Show me whenever you're ready.",
);
// 3.5s gives most readers enough time to actually parse the
// recovery hint. Earlier 1.4s value was tuned for "snappy" but
// the popup was vanishing before users could read it.
await new Promise<void>((r) => window.setTimeout(r, 3500));
}
} catch {
/* 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.
try {
await ac.fadeOut(spawnPoint);
} catch {
/* swallow */
}
} finally {
if (highlightCleanup.current) {
highlightCleanup.current();
highlightCleanup.current = null;
}
store.dispatch(setRunning(false));
clearStepTiming();
}
}
async function runOps(ops: ACOp[], ctx: RunContext): Promise<void> {
for (let i = 0; i < ops.length; i++) {
const op = ops[i];
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,
op_index: i,
op_kind: op.kind,
});
}
const opStart = Date.now();
try {
await runOp(op, ctx);
if (!ctx.silent) {
report('op_completed', {
step_id: ctx.stepId,
op_index: i,
op_kind: op.kind,
duration_ms: Date.now() - opStart,
});
}
} catch (err) {
if (!ctx.silent && (err as DOMException)?.name !== 'AbortError') {
report('op_failed', {
step_id: ctx.stepId,
op_index: i,
op_kind: op.kind,
duration_ms: Date.now() - opStart,
error: String(err),
});
}
throw err;
}
}
}
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).
const clearsTransients =
op.kind === 'move_to' ||
op.kind === 'click' ||
op.kind === 'type_into' ||
op.kind === 'drag_select' ||
op.kind === 'outro';
if (clearsTransients) {
ac.hidePopup();
ac.stopTracking();
if (ctx.highlightCleanup.current) {
ctx.highlightCleanup.current();
ctx.highlightCleanup.current = null;
}
}
switch (op.kind) {
case 'move_to': {
// Pre-flight: if the target lives inside a collapsed Customization
// section, walk the user through expanding it first. This is the
// "asks me to click on it twice" fix — 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.
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).
const SCROLL_SETTLE_MAX_MS = 1000;
const POLL_MS = 60;
const startedAt = performance.now();
const needsSettle = scrolled || isBroken(r, cy) || isOffViewport(cy);
if (needsSettle) {
while (performance.now() - startedAt < SCROLL_SETTLE_MAX_MS) {
await sleep(POLL_MS);
r = el.getBoundingClientRect();
cx = r.left + r.width / 2 + offX;
cy = r.top + r.height / 2 + offY;
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`);
}
await ac.moveTo(cx, cy);
ac.startTracking(op.target, op.offset);
return;
}
case 'popup': {
if (ctx.silent) return;
ac.showPopup(op.text);
return;
}
case 'multi_choice': {
if (ctx.silent) return;
const id = await ac.showMultiChoice(op.question, op.options);
if (id) {
store.dispatch(
recordMultiChoice({ stepId: ctx.stepId, opId: op.opId, answerId: id }),
);
report('multi_choice_answered', {
step_id: ctx.stepId,
op_id: op.opId,
answer_id: id,
});
}
const choice = op.options.find((o) => o.id === id);
if (choice?.thenOps?.length) {
await runOps(choice.thenOps, ctx);
}
return;
}
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) {
ac.showPopup(op.popup);
}
// 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': {
const el = await waitForSelector(op.target);
if (scrollIntoViewIfNeeded(el)) {
await sleep(180);
}
const r = el.getBoundingClientRect();
await ac.moveTo(Math.min(r.right - 14, r.left + r.width / 2), r.top + r.height / 2);
ac.startTracking(op.target, { x: 0, y: 0 });
await typeInto(el, op.text, { speedMs: op.speedMs });
return;
}
case 'click': {
const el = await waitForSelector(op.target);
if (scrollIntoViewIfNeeded(el)) {
await sleep(180);
}
const r = el.getBoundingClientRect();
const x = r.left + r.width / 2;
const y = r.top + r.height / 2;
await ac.moveTo(x, y);
await ac.pressClick();
clickRipple(x, y, accentColor);
if (op.simulate !== false) {
try {
el.click();
} catch {
/* 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.
return;
}
case 'drag_select': {
const el = await waitForSelector(op.target);
if (scrollIntoViewIfNeeded(el)) {
await sleep(180);
}
const r = el.getBoundingClientRect();
const fromX = r.left - 12;
const fromY = r.top - 12;
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.
const RECT_DURATION_MS = 600;
await Promise.all([
animateDragSelect(
{ fromX, fromY, toX, toY },
accentColor,
RECT_DURATION_MS,
),
ac.moveTo(toX, toY, {
duration: RECT_DURATION_MS / 1000,
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': {
await waitForCondition(op.condition, signal, store, op.timeoutMs);
ac.hidePopup();
// 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;
}
case 'delay': {
await new Promise<void>((resolve, reject) => {
const timer = window.setTimeout(resolve, op.ms);
const onAbort = () => {
window.clearTimeout(timer);
signal.removeEventListener('abort', onAbort);
reject(new DOMException('aborted', 'AbortError'));
};
signal.addEventListener('abort', onAbort);
});
return;
}
case 'outro': {
await ac.fadeOut(ctx.spawnPoint);
return;
}
}
}
// 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).
function scrollIntoViewIfNeeded(el: HTMLElement): boolean {
const r = el.getBoundingClientRect();
const vh = window.innerHeight;
const vw = window.innerWidth;
const PAD = 24;
const offTop = r.top < PAD;
const offBottom = r.bottom > vh - PAD;
const offLeft = r.left < PAD;
const offRight = r.right > vw - PAD;
if (!offTop && !offBottom && !offLeft && !offRight) return false;
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 */
}
}
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.
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().
function buildOpenDashboardOps(): ACOp[] {
const sectionEl = document.querySelector<HTMLElement>(
'[data-onboarding="sidebar-dashboards"]',
);
const sectionExpanded =
sectionEl?.dataset.expanded === 'true' ||
sectionEl?.getAttribute('aria-expanded') === 'true';
const ops: ACOp[] = [];
if (!sectionExpanded) {
ops.push(
{ kind: 'move_to', target: 'sidebar-dashboards' },
{ kind: 'popup', text: 'Open the Dashboards list.' },
{
kind: 'wait_user',
condition: { kind: 'click_target', target: 'sidebar-dashboards' },
timeoutMs: 60000,
},
);
}
ops.push(
{ kind: 'move_to', target: 'dashboard-row-first' },
{ kind: 'popup', text: 'Click into a dashboard to continue.' },
{
kind: 'wait_user',
condition: { kind: 'click_target', target: 'dashboard-row-first' },
timeoutMs: 60000,
},
);
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',
]);
/**
* 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>(
'[data-onboarding="sidebar-customization"]',
);
const expanded =
header?.dataset.expanded === 'true' ||
header?.getAttribute('aria-expanded') === 'true';
if (expanded) return null;
return [
{ kind: 'move_to', target: 'sidebar-customization' },
{ kind: 'popup', text: 'Open Customization.' },
{
kind: 'wait_user',
condition: { kind: 'click_target', target: 'sidebar-customization' },
timeoutMs: 60000,
},
];
}
function waitForCondition(
cond: AdvanceCondition,
signal: AbortSignal,
store: Store<RootState>,
timeoutMs?: number,
): Promise<void> {
if (signal.aborted) {
return Promise.reject(new DOMException('aborted', 'AbortError'));
}
return new Promise((resolve, reject) => {
let cleanup: () => void = () => {};
let timer: number | null = null;
const finish = () => {
cleanup();
if (timer !== null) window.clearTimeout(timer);
signal.removeEventListener('abort', onAbort);
resolve();
};
const onAbort = () => {
cleanup();
if (timer !== null) window.clearTimeout(timer);
reject(new DOMException('aborted', 'AbortError'));
};
signal.addEventListener('abort', onAbort);
if (timeoutMs && timeoutMs > 0) {
timer = window.setTimeout(() => {
cleanup();
signal.removeEventListener('abort', onAbort);
// Treat timeout as soft-success — the user may have done the thing
// without our condition firing (e.g. they opened the page some other
// way). Better than freezing the panel.
resolve();
}, timeoutMs);
}
switch (cond.kind) {
case 'click_target': {
const handler = (e: Event) => {
const el = e.target as HTMLElement | null;
if (
el?.closest(
`[data-onboarding="${cond.target}"], [data-select-type="${cond.target}"]`,
)
) {
finish();
}
};
document.addEventListener('click', handler, true);
cleanup = () => document.removeEventListener('click', handler, true);
return;
}
case 'redux_predicate': {
const check = () => {
const value = cond.selector(store.getState());
const ok =
cond.equals !== undefined
? value === cond.equals
: cond.truthy
? Boolean(value)
: Boolean(value);
if (ok) finish();
};
check();
const unsub = store.subscribe(check);
cleanup = unsub;
return;
}
case 'event_bus': {
const off = onboardingBus.once(cond.event as OnboardingEvent, () =>
finish(),
);
cleanup = off;
return;
}
}
});
}