mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-30 21:44:50 +02:00
[eric] onboarding 1.0.31 + Settings draft persistence + LM Studio fix
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import React, { useState, useEffect, useRef, useCallback, startTransition, useMemo } from 'react';
|
||||
import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { openSettingsModal } from '@/shared/state/settingsSlice';
|
||||
import Box from '@mui/material/Box';
|
||||
@@ -65,7 +65,22 @@ const CUSTOMIZATION_PATHS = new Set(CUSTOMIZATION_ITEMS.map((i) => i.path));
|
||||
const AppShell: React.FC = () => {
|
||||
const c = useClaudeTokens();
|
||||
const dispatch = useAppDispatch();
|
||||
const navigate = useNavigate();
|
||||
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.
|
||||
const navigate = useMemo(() => {
|
||||
const fn = (...args: Parameters<typeof navigateRaw>) => {
|
||||
startTransition(() => {
|
||||
(navigateRaw as any)(...args);
|
||||
});
|
||||
};
|
||||
return fn as typeof navigateRaw;
|
||||
}, [navigateRaw]);
|
||||
const location = useLocation();
|
||||
const [dashboardsExpanded, setDashboardsExpanded] = useState(true);
|
||||
const [appsExpanded, setAppsExpanded] = useState(true);
|
||||
@@ -828,48 +843,51 @@ 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) => (
|
||||
<NavLink
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
style={{ textDecoration: 'none', color: 'inherit' }}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<Box
|
||||
data-onboarding={item.onboarding}
|
||||
{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.
|
||||
const isActive = location.pathname === item.path;
|
||||
return (
|
||||
<Box
|
||||
key={item.path}
|
||||
data-onboarding={item.onboarding}
|
||||
onClick={() => navigate(item.path)}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.75,
|
||||
pl: 1.25,
|
||||
pr: 1,
|
||||
py: 0.5,
|
||||
ml: '-0.5px',
|
||||
cursor: 'pointer',
|
||||
borderLeft: isActive ? `1.5px solid ${c.accent.primary}` : '1.5px solid transparent',
|
||||
bgcolor: isActive ? `${c.accent.primary}0C` : 'transparent',
|
||||
'&:hover': { bgcolor: `${c.text.tertiary}0A` },
|
||||
transition: 'background-color 0.12s, border-color 0.12s',
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.75,
|
||||
pl: 1.25,
|
||||
pr: 1,
|
||||
py: 0.5,
|
||||
ml: '-0.5px',
|
||||
cursor: 'pointer',
|
||||
borderLeft: isActive ? `1.5px solid ${c.accent.primary}` : '1.5px solid transparent',
|
||||
bgcolor: isActive ? `${c.accent.primary}0C` : 'transparent',
|
||||
'&:hover': { bgcolor: `${c.text.tertiary}0A` },
|
||||
transition: 'background-color 0.12s, border-color 0.12s',
|
||||
color: isActive ? c.text.secondary : c.text.ghost,
|
||||
fontSize: '0.78rem',
|
||||
fontWeight: isActive ? 500 : 400,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
sx={{
|
||||
color: isActive ? c.text.secondary : c.text.ghost,
|
||||
fontSize: '0.78rem',
|
||||
fontWeight: isActive ? 500 : 400,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</NavLink>
|
||||
))}
|
||||
{item.label}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Box>
|
||||
|
||||
@@ -115,15 +115,27 @@ class OnboardingDirector {
|
||||
window.addEventListener('hashchange', onRouteChange);
|
||||
|
||||
try {
|
||||
try {
|
||||
await this.runPreStepHook(step);
|
||||
} catch (err) {
|
||||
// Fire the pre-step hook in the BACKGROUND instead of awaiting it.
|
||||
// For step 6, the hook posts `seed-orchestration-demo` which can
|
||||
// take 15-30s when Anthropic is rate-limiting (the meta-generation
|
||||
// it triggers internally hits 429s and retries). Awaiting it
|
||||
// blocked the entire AC flow — user sees no cursor, no popup,
|
||||
// appears completely frozen.
|
||||
//
|
||||
// Now: hook fires in parallel with AC's intro animation + first
|
||||
// popup + user clicks. By the time AC reaches the drag_select op
|
||||
// that actually needs the stub agent (several user interactions
|
||||
// in), the seed has long since completed. If the seed fails
|
||||
// outright, drag_select's waitForSelector will time out into the
|
||||
// normal recovery path — same as any other "target not found"
|
||||
// case. Never blocks the user-visible startup.
|
||||
this.runPreStepHook(step).catch((err) => {
|
||||
console.warn('[onboarding] preStepHook failed', step.id, err);
|
||||
report('pre_step_hook_failed', {
|
||||
step_id: step.id,
|
||||
error: String(err),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
await runStep({
|
||||
step,
|
||||
@@ -159,8 +171,17 @@ class OnboardingDirector {
|
||||
* "OpenSwarm research" (the seed endpoint uses that name) and only
|
||||
* call seed-orchestration-demo when nothing matches — so re-running
|
||||
* step 6 doesn't keep adding stub agents.
|
||||
*
|
||||
* In-flight dedup: rapid Show me clicks used to fire N parallel
|
||||
* seed calls because Redux state didn't update until the FIRST one
|
||||
* completed (and synced back via websocket). The promise cache
|
||||
* collapses concurrent callers to a single backend POST.
|
||||
*/
|
||||
private seedInFlight: Promise<void> | null = null;
|
||||
|
||||
private async ensureStubResearchAgent(): Promise<void> {
|
||||
if (this.seedInFlight) return this.seedInFlight;
|
||||
|
||||
const state = this.store!.getState();
|
||||
const sessions = (state as any).agents?.sessions ?? {};
|
||||
const alreadySeeded = Object.values(sessions).some(
|
||||
@@ -174,16 +195,20 @@ class OnboardingDirector {
|
||||
null;
|
||||
if (!dashboardId) return;
|
||||
|
||||
try {
|
||||
await fetch(
|
||||
`${API_BASE}/dashboards/${dashboardId}/seed-orchestration-demo`,
|
||||
{ method: 'POST' },
|
||||
);
|
||||
report('stub_research_agent_seeded', { step_id: 'agent_control_agents' });
|
||||
} catch (err) {
|
||||
// Non-fatal; user just won't see the stub. Better than blocking.
|
||||
console.warn('[onboarding] seed-orchestration-demo failed', err);
|
||||
}
|
||||
this.seedInFlight = (async () => {
|
||||
try {
|
||||
await fetch(
|
||||
`${API_BASE}/dashboards/${dashboardId}/seed-orchestration-demo`,
|
||||
{ method: 'POST' },
|
||||
);
|
||||
report('stub_research_agent_seeded', { step_id: 'agent_control_agents' });
|
||||
} catch (err) {
|
||||
console.warn('[onboarding] seed-orchestration-demo failed', err);
|
||||
} finally {
|
||||
this.seedInFlight = null;
|
||||
}
|
||||
})();
|
||||
return this.seedInFlight;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import { STEPS, findStepById } from './steps';
|
||||
import { STAGE_LABELS } from './steps/types';
|
||||
import { onboardingDirector } from './OnboardingDirector';
|
||||
import { report } from './telemetry';
|
||||
import { cursorStore } from './ac/cursorStore';
|
||||
import OnboardingRoadmapModal from './OnboardingRoadmapModal';
|
||||
|
||||
const PANEL_WIDTH = 320;
|
||||
@@ -67,6 +68,10 @@ const OnboardingPanel: React.FC = () => {
|
||||
// Cursor icon inside the "Show me" button — used to calculate the AC
|
||||
// spawn point so the cursor visually flies out of this exact 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).
|
||||
const lastShowMeClickRef = useRef<number>(0);
|
||||
|
||||
// Resolve current step. Prefer explicit currentStepId; fall back to
|
||||
// first uncompleted step.
|
||||
@@ -103,7 +108,31 @@ const OnboardingPanel: React.FC = () => {
|
||||
|
||||
const handleShowMe = async () => {
|
||||
if (!currentStep) return;
|
||||
if (progress.running) 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.
|
||||
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.
|
||||
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
|
||||
@@ -117,6 +146,20 @@ const OnboardingPanel: React.FC = () => {
|
||||
? { 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.
|
||||
const watchedStepId = currentStep.id;
|
||||
window.setTimeout(() => {
|
||||
const acVisible = cursorStore.get().visible;
|
||||
if (!acVisible) {
|
||||
onboardingDirector.cancelStep();
|
||||
progress.setRunning(false);
|
||||
report('show_me_watchdog_recovery', { step_id: watchedStepId });
|
||||
}
|
||||
}, 2000);
|
||||
await onboardingDirector.startStep(currentStep.id, spawnPoint);
|
||||
};
|
||||
|
||||
@@ -763,50 +806,50 @@ const InfoPopover: React.FC<InfoPopoverProps> = ({ stepId, anchorRef, onClose, t
|
||||
};
|
||||
|
||||
const INFO_BY_STEP_ID: Record<string, string> = {
|
||||
connect_model: `Open Swarm is designed to be model-agnostic so it works with any AI model.
|
||||
connect_model: `Open Swarm works with any AI model.
|
||||
|
||||
If you already have a subscription to ChatGPT, Claude, or Gemini, you can plug those directly into Open Swarm.
|
||||
If you already have a subscription to ChatGPT, Claude, or Gemini, plug it directly into Open Swarm.
|
||||
|
||||
We also offer an Open Swarm subscription that gives you the same usage as these model providers.
|
||||
We also offer an Open Swarm subscription that gives you the same usage as those providers.
|
||||
|
||||
Optionally you can choose to instead use API Keys directly.`,
|
||||
Or use your own API keys.`,
|
||||
enable_actions: `Actions are the capabilities available to your AI agents.
|
||||
|
||||
Every tool call an agent makes — reading a file, sending an email, searching the web — is an action.
|
||||
Every tool an agent uses (reading a file, sending an email, searching the web) is an action.
|
||||
|
||||
Every action in Open Swarm has a permission policy that decides if an agent can use it and whether it requires your permission.
|
||||
Every action has a permission policy. It decides whether an agent can use it on its own or whether it needs your approval first.
|
||||
|
||||
The Actions page is where you configure which actions are available, how they're authenticated, and what permissions they require.`,
|
||||
The Actions page is where you turn integrations on, sign in, and tune those permissions.`,
|
||||
launch_agent: `An agent in Open Swarm can do anything you can do on your computer.
|
||||
|
||||
They can read and write files, run commands, search the web, control a browser, send emails, manage your calendar — and handle long-running, multi-step tasks autonomously.
|
||||
It can read and write files, run commands, search the web, control a browser, send emails, manage your calendar, and handle long, multi step tasks on its own.
|
||||
|
||||
Think of each agent as a teammate you can brief on a task and let loose, while you watch it work in real time.`,
|
||||
use_browser: `Open Swarm has built-in browsers so you never have to jump between apps. Stay in one place, stay in the zone — just one seamless workspace for you and your agents.
|
||||
use_browser: `Open Swarm has built in browsers so you never have to jump between apps. One place, one workspace, for you and your agents.
|
||||
|
||||
The browsers aren't just for you though — your agents can use them too. By default an agent can create and use its own browsers as needed.
|
||||
The browsers aren't just for you. Your agents can use them too. By default an agent can spin up and use its own browser whenever it needs one.
|
||||
|
||||
In the next step we'll see how you can have an agent take over a browser that you yourself were using.`,
|
||||
agent_use_browser: `This video shows how you can have an agent control browsers that already exist in your canvas. In addition to this, agents can create and use their own browsers as needed.
|
||||
In the next step we'll see how to hand off a browser you're using to an agent.`,
|
||||
agent_use_browser: `This shows how to give an agent control of a browser already on your canvas. Agents can also spin up their own browsers whenever they need one.
|
||||
|
||||
Note: In this demo, we saw you select a single browser and send it to an agent. That said, you can also select multiple browsers.
|
||||
In this demo you handed off a single browser, but you can also hand off multiple browsers at once.
|
||||
|
||||
Under the hood, each browser is controlled by its own specialized agent which communicates with the agent pointing to the browser.`,
|
||||
agent_control_agents: `Similarly to browsers, while you have the ability to manually select which agents work together, an agent can choose to spawn its own employees as needed.
|
||||
Under the hood, each browser is run by its own specialized agent that talks back to the agent you handed it to.`,
|
||||
agent_control_agents: `Just like browsers, you can pick which agents work together. Or an agent can spin up its own helpers whenever it needs to.
|
||||
|
||||
When a task gets too complicated for a single agent, it has the ability to spawn its own sub-agents as it deems fit.
|
||||
When a task gets too big for one agent, it spins up sub agents to share the load.
|
||||
|
||||
After a sub-agent has completed, it will collapse back into its parent agent. You can always re-expand a sub-agent from the parent chat by clicking "Reveal in dashboard".`,
|
||||
install_skill: `An agent on its own is a capable general-purpose reasoner. It can handle a lot — but it doesn't know the specifics of your workflows, your output formats, your domain expertise.
|
||||
When a sub agent finishes, it collapses back into its parent. You can always reopen it from the parent chat by clicking "Reveal in dashboard".`,
|
||||
install_skill: `An agent on its own is a strong general purpose reasoner. It can do a lot, but it doesn't know the specifics of your workflows, your output formats, or your domain.
|
||||
|
||||
Skills fill that gap.
|
||||
|
||||
A skill is a set of instructions that teach an agent how to approach a specific type of task. When a skill is active, the agent follows its guidance — producing better, more consistent results for that domain than it would on its own.`,
|
||||
make_app: `Apps are interactive, AI-generated web applications that live inside OpenSwarm.
|
||||
A skill is a set of instructions that teach an agent how to handle a specific kind of task. When a skill is active, the agent follows its guidance and produces better, more consistent results in that area.`,
|
||||
make_app: `Apps are interactive, AI generated web applications that live inside OpenSwarm.
|
||||
|
||||
Instead of paying for software or spending weeks building UIs, you describe what you want and an agent writes it for you — a live, runnable app appears in seconds.
|
||||
Instead of paying for software or spending weeks building a UI, you describe what you want and an agent writes it for you. A live, runnable app appears in seconds.
|
||||
|
||||
After making an App, you can open it in your canvas alongside agents and browsers.`,
|
||||
Once it's made, you can pop the App into your canvas alongside agents and browsers.`,
|
||||
};
|
||||
|
||||
export default OnboardingPanel;
|
||||
|
||||
@@ -82,6 +82,14 @@ export interface TypeIntoOptions {
|
||||
onTick?: () => void;
|
||||
}
|
||||
|
||||
function readEffectiveText(el: HTMLElement): string {
|
||||
if (el.isContentEditable) return (el.textContent ?? '').trim();
|
||||
if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) {
|
||||
return (el.value ?? '').trim();
|
||||
}
|
||||
return (el.textContent ?? '').trim();
|
||||
}
|
||||
|
||||
export async function typeInto(
|
||||
el: HTMLElement,
|
||||
text: string,
|
||||
@@ -102,18 +110,79 @@ export async function typeInto(
|
||||
opts.onTick?.();
|
||||
await new Promise((r) => window.setTimeout(r, speed));
|
||||
}
|
||||
return;
|
||||
} else {
|
||||
let acc = '';
|
||||
if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) {
|
||||
acc = el.value ?? '';
|
||||
}
|
||||
for (const ch of text) {
|
||||
acc += ch;
|
||||
nativeSetValue(el, acc);
|
||||
dispatchInput(el);
|
||||
opts.onTick?.();
|
||||
await new Promise((r) => window.setTimeout(r, speed));
|
||||
}
|
||||
}
|
||||
|
||||
let acc = '';
|
||||
if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) {
|
||||
acc = el.value ?? '';
|
||||
// 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.
|
||||
const target = text.trim();
|
||||
if (!target) return;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await new Promise<void>((r) => window.setTimeout(r, 100));
|
||||
const got = readEffectiveText(el);
|
||||
if (got.length >= Math.floor(target.length * 0.8)) return;
|
||||
}
|
||||
for (const ch of text) {
|
||||
acc += ch;
|
||||
nativeSetValue(el, acc);
|
||||
dispatchInput(el);
|
||||
opts.onTick?.();
|
||||
await new Promise((r) => window.setTimeout(r, speed));
|
||||
|
||||
// Fallback: nuke contents and insert the full string in one shot.
|
||||
// Loses the typing animation but preserves the user-visible outcome.
|
||||
try {
|
||||
if (el.isContentEditable) {
|
||||
el.focus();
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(el);
|
||||
const sel = window.getSelection();
|
||||
if (sel) {
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
}
|
||||
try {
|
||||
document.execCommand('delete', false);
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
try {
|
||||
const ok = document.execCommand('insertText', false, text);
|
||||
if (!ok) {
|
||||
el.textContent = text;
|
||||
el.dispatchEvent(
|
||||
new InputEvent('input', {
|
||||
bubbles: true,
|
||||
data: text,
|
||||
inputType: 'insertText',
|
||||
}),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
el.textContent = text;
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
} else if (
|
||||
el instanceof HTMLInputElement ||
|
||||
el instanceof HTMLTextAreaElement
|
||||
) {
|
||||
nativeSetValue(el, text);
|
||||
dispatchInput(el);
|
||||
}
|
||||
} catch {
|
||||
/* best-effort — runtime's wait_user will time out and recover */
|
||||
}
|
||||
}
|
||||
|
||||
@@ -323,7 +323,52 @@ async function runOp(op: ACOp, ctx: RunContext): Promise<void> {
|
||||
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.
|
||||
const STABILITY_MAX_MS = 200;
|
||||
const STABILITY_THRESHOLD_PX = 1.5;
|
||||
const stabilityStart = performance.now();
|
||||
let prevCx = cx;
|
||||
let prevCy = cy;
|
||||
let stableFrames = 0;
|
||||
while (
|
||||
stableFrames < 2 &&
|
||||
performance.now() - stabilityStart < STABILITY_MAX_MS
|
||||
) {
|
||||
await new Promise<void>((res) => requestAnimationFrame(() => res()));
|
||||
r = el.getBoundingClientRect();
|
||||
cx = r.left + r.width / 2 + offX;
|
||||
cy = r.top + r.height / 2 + offY;
|
||||
if (
|
||||
Math.abs(cx - prevCx) <= STABILITY_THRESHOLD_PX &&
|
||||
Math.abs(cy - prevCy) <= STABILITY_THRESHOLD_PX
|
||||
) {
|
||||
stableFrames += 1;
|
||||
} else {
|
||||
stableFrames = 0;
|
||||
}
|
||||
prevCx = cx;
|
||||
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.
|
||||
await new Promise<void>((r) => requestAnimationFrame(() => r()));
|
||||
ac.startTracking(op.target, op.offset);
|
||||
return;
|
||||
}
|
||||
@@ -383,6 +428,45 @@ async function runOp(op: ACOp, ctx: RunContext): Promise<void> {
|
||||
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 });
|
||||
// Anti-revert guard: some controlled contentEditable libraries
|
||||
// re-render on their own schedule and wipe AC's typed text in
|
||||
// the next React commit. Re-check the input value after a brief
|
||||
// beat and re-insert if it got wiped. Without this, the next
|
||||
// op (typically click send) hits a disabled send button because
|
||||
// the input "thinks" it's empty.
|
||||
await sleep(80);
|
||||
const readText = (e: HTMLElement): string => {
|
||||
if (e.isContentEditable) return (e.textContent ?? '').trim();
|
||||
if (e instanceof HTMLInputElement || e instanceof HTMLTextAreaElement)
|
||||
return (e.value ?? '').trim();
|
||||
return (e.textContent ?? '').trim();
|
||||
};
|
||||
const target = op.text.trim();
|
||||
if (target && readText(el).length < Math.floor(target.length * 0.8)) {
|
||||
// Single-shot re-insert. Same path the typewriter's own
|
||||
// fallback uses for under-load typing drops.
|
||||
if (el.isContentEditable) {
|
||||
el.focus();
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(el);
|
||||
const sel = window.getSelection();
|
||||
if (sel) {
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
}
|
||||
try {
|
||||
document.execCommand('delete', false);
|
||||
const ok = document.execCommand('insertText', false, op.text);
|
||||
if (!ok) {
|
||||
el.textContent = op.text;
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
} catch {
|
||||
el.textContent = op.text;
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
case 'click': {
|
||||
@@ -397,6 +481,29 @@ 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.
|
||||
const isDisabled = (n: HTMLElement | null): boolean => {
|
||||
while (n) {
|
||||
if (n.hasAttribute('disabled')) return true;
|
||||
if (n.getAttribute('aria-disabled') === 'true') return true;
|
||||
n = n.parentElement;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
if (isDisabled(el)) {
|
||||
await new Promise<void>((res) =>
|
||||
requestAnimationFrame(() => res()),
|
||||
);
|
||||
await sleep(120);
|
||||
}
|
||||
try {
|
||||
el.click();
|
||||
} catch {
|
||||
@@ -418,7 +525,29 @@ async function runOp(op: ACOp, ctx: RunContext): Promise<void> {
|
||||
if (scrollIntoViewIfNeeded(el)) {
|
||||
await sleep(180);
|
||||
}
|
||||
const r = el.getBoundingClientRect();
|
||||
// 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.
|
||||
let r = el.getBoundingClientRect();
|
||||
const stableStart = performance.now();
|
||||
let prevLeft = r.left;
|
||||
let prevTop = r.top;
|
||||
let stableFrames = 0;
|
||||
while (stableFrames < 2 && performance.now() - stableStart < 500) {
|
||||
await new Promise<void>((res) => requestAnimationFrame(() => res()));
|
||||
r = el.getBoundingClientRect();
|
||||
if (Math.abs(r.left - prevLeft) <= 1.5 && Math.abs(r.top - prevTop) <= 1.5) {
|
||||
stableFrames += 1;
|
||||
} else {
|
||||
stableFrames = 0;
|
||||
}
|
||||
prevLeft = r.left;
|
||||
prevTop = r.top;
|
||||
}
|
||||
const fromX = r.left - 12;
|
||||
const fromY = r.top - 12;
|
||||
const toX = r.right + 12;
|
||||
|
||||
@@ -176,9 +176,14 @@ export function resolveSelector(target: string): HTMLElement | null {
|
||||
// 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.
|
||||
export function waitForSelector(
|
||||
target: string,
|
||||
timeoutMs = 8000,
|
||||
timeoutMs = 15000,
|
||||
): Promise<HTMLElement> {
|
||||
const existing = resolveSelector(target);
|
||||
if (existing) return Promise.resolve(existing);
|
||||
|
||||
@@ -24,7 +24,12 @@ export const step03: OnboardingStep = {
|
||||
{
|
||||
kind: 'type_into',
|
||||
target: S.chatInput,
|
||||
text: 'What is this youtube video about: https://youtu.be/_NKj8KQMY-k?si=rEk4KO2bOpa5Vo0z',
|
||||
// Anti-browser-agent directive: the YouTube summary can be
|
||||
// answered entirely from the youtube transcript MCP without
|
||||
// spawning a browser-agent. Browser agents misbehave under
|
||||
// load (ReportProgress violation loops, rate-limit retries)
|
||||
// and tank dashboard responsiveness. The transcript is plenty.
|
||||
text: 'What is this youtube video about: https://youtu.be/_NKj8KQMY-k?si=rEk4KO2bOpa5Vo0z. Do not use browser agents.',
|
||||
speedMs: 12,
|
||||
},
|
||||
// Auto-send the prompt — same pattern as steps 5/6/8. Without this,
|
||||
|
||||
@@ -18,11 +18,13 @@ export const step05: OnboardingStep = {
|
||||
kind: 'wait_user',
|
||||
condition: { kind: 'click_target', target: S.newAgentButton },
|
||||
},
|
||||
// Offset nudge: cursor SVG is asymmetric (tip top-left, body down-right),
|
||||
// so default rect-center pinning makes the body bleed over the
|
||||
// paperclip "Attach file" button right next to this icon. Pulling
|
||||
// the tip ~8px left keeps the body inside this icon's footprint.
|
||||
{ kind: 'move_to', target: S.elementSelectionToggle, offset: { x: -8, y: 0 } },
|
||||
// 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.
|
||||
{ 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',
|
||||
|
||||
@@ -24,8 +24,9 @@ export const step06: OnboardingStep = {
|
||||
kind: 'wait_user',
|
||||
condition: { kind: 'click_target', target: S.newAgentButton },
|
||||
},
|
||||
// See step05 — same nudge to keep cursor body off the adjacent paperclip.
|
||||
{ kind: 'move_to', target: S.elementSelectionToggle, offset: { x: -8, y: 0 } },
|
||||
// See step05 — same nudge so the cursor's visual body center sits
|
||||
// over the select-mode icon, not the adjacent paperclip.
|
||||
{ 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',
|
||||
@@ -41,7 +42,7 @@ export const step06: OnboardingStep = {
|
||||
{ kind: 'drag_select', target: 'agent-card' },
|
||||
{
|
||||
kind: 'popup',
|
||||
text: 'Your turn! Lasso the chat to make it a helper.',
|
||||
text: 'Now you try! Drag a box around the chat to make it a helper.',
|
||||
},
|
||||
{
|
||||
kind: 'wait_user',
|
||||
|
||||
@@ -22,6 +22,22 @@ 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 (model probe
|
||||
// + initial fetch). Without this delay AC tries to type into an
|
||||
// input that's either not yet mounted or mounted-but-not-wired
|
||||
// to the App Builder agent's state machine. Characters land in
|
||||
// the DOM but get discarded on first render commit.
|
||||
//
|
||||
// 1500ms covers the typical mount + model probe round-trip even
|
||||
// under main-thread starvation from concurrent agent streams.
|
||||
// waitForSelector below ALSO retries on its own, so this is a
|
||||
// belt-and-suspenders preflight, not the primary wait.
|
||||
{
|
||||
kind: 'popup',
|
||||
text: 'Loading the App Builder...',
|
||||
},
|
||||
{ kind: 'delay', ms: 1500 },
|
||||
// The App Builder chat lives in the left pane on /apps/new — a
|
||||
// regular ChatInput instance, so data-onboarding="chat-input"
|
||||
// resolves to it.
|
||||
|
||||
Reference in New Issue
Block a user