diff --git a/frontend/src/app/components/Onboarding/OnboardingPanel.tsx b/frontend/src/app/components/Onboarding/OnboardingPanel.tsx index ac809bac..37643611 100644 --- a/frontend/src/app/components/Onboarding/OnboardingPanel.tsx +++ b/frontend/src/app/components/Onboarding/OnboardingPanel.tsx @@ -93,20 +93,13 @@ const OnboardingPanel: React.FC = () => { const done = progress.completedSteps.length; // Celebration banner — strike-through + check on the just-completed - // step. Auto-clears so we transition into the next step's card. - // Depend ONLY on the id (stable across renders); dispatching from - // the slice action directly avoids re-running the effect when the - // useOnboardingProgress wrapper produces a new clearJustCompleted - // reference each render (which would reset the timer endlessly). + // 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. const justDoneStepId = progress.justCompletedStepId; const justDoneStep = justDoneStepId ? findStepById(justDoneStepId) : null; - useEffect(() => { - if (!justDoneStepId) return; - const t = window.setTimeout(() => { - dispatch(clearJustCompleted()); - }, CELEBRATION_MS); - return () => window.clearTimeout(t); - }, [justDoneStepId, dispatch]); const handleShowMe = async () => { if (!currentStep) return; @@ -564,6 +557,21 @@ interface CelebrationProps { const CelebrationView: React.FC = ({ 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. + 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 ( diff --git a/frontend/src/app/components/Onboarding/ac/acRuntime.ts b/frontend/src/app/components/Onboarding/ac/acRuntime.ts index d48b7221..094b54ea 100644 --- a/frontend/src/app/components/Onboarding/ac/acRuntime.ts +++ b/frontend/src/app/components/Onboarding/ac/acRuntime.ts @@ -282,30 +282,45 @@ async function runOp(op: ACOp, ctx: RunContext): Promise { const offX = op.offset?.x ?? 0; const offY = op.offset?.y ?? 0; const TITLE_BAR_BOTTOM = 38; - const looksDegenerate = (rr: DOMRect, y: number): boolean => + // "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 || - y > window.innerHeight || 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; - if (scrolled || looksDegenerate(r, cy)) { - // Either we just kicked off a smooth scroll, or the rect looks - // mid-commit. Wait one frame's worth (16ms) and re-read; only - // fall back to the longer wait if it's still bad. - await sleep(scrolled ? 180 : 16); - r = el.getBoundingClientRect(); - cx = r.left + r.width / 2 + offX; - cy = r.top + r.height / 2 + offY; - if (looksDegenerate(r, cy)) { - await sleep(160); + // 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; } } - if (looksDegenerate(r, cy)) { + // 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); diff --git a/frontend/src/app/components/Onboarding/steps/step05_agentUseBrowser.ts b/frontend/src/app/components/Onboarding/steps/step05_agentUseBrowser.ts index 186c0a57..ed897207 100644 --- a/frontend/src/app/components/Onboarding/steps/step05_agentUseBrowser.ts +++ b/frontend/src/app/components/Onboarding/steps/step05_agentUseBrowser.ts @@ -28,6 +28,17 @@ export const step05: OnboardingStep = { 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. + { 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). diff --git a/frontend/src/app/components/Onboarding/steps/step06_agentControlAgents.ts b/frontend/src/app/components/Onboarding/steps/step06_agentControlAgents.ts index 97b34fbe..828469c0 100644 --- a/frontend/src/app/components/Onboarding/steps/step06_agentControlAgents.ts +++ b/frontend/src/app/components/Onboarding/steps/step06_agentControlAgents.ts @@ -31,6 +31,13 @@ export const step06: OnboardingStep = { 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 + // research card off-screen. Click fit-to-view first so both cards + // are visible together for the drag-select demo. + { kind: 'move_to', target: S.canvasFitToView }, + { kind: 'click', target: S.canvasFitToView, simulate: true }, + { kind: 'delay', ms: 350 }, { kind: 'drag_select', target: 'agent-card' }, { kind: 'popup', diff --git a/frontend/src/app/pages/Settings/Settings.tsx b/frontend/src/app/pages/Settings/Settings.tsx index 5d5689c9..76fe89b9 100644 --- a/frontend/src/app/pages/Settings/Settings.tsx +++ b/frontend/src/app/pages/Settings/Settings.tsx @@ -1388,11 +1388,19 @@ const Settings: React.FC = () => { if (open && !initialTab) setActiveTab('general'); }, [open, initialTab]); + // Sync form to Redux settings on modal open / first load only — NOT on + // every settings change. Including `settings` in the deps causes any + // background dispatch that touches state.data (the SignInGate's 2s + // fetchSettings poll, the window-focus refetch in SettingsLoader, the + // updateSettings response, etc.) to wipe the user's in-flight edits + // mid-typing — that's the "save button flashes and the key disappears" + // report from issue #25. useEffect(() => { - if (loaded) { + if (open && loaded) { setForm({ ...settings }); } - }, [loaded, settings]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, loaded]); const handleCheckForUpdates = async () => { dispatch(setChecking()); diff --git a/frontend/src/app/pages/Views/ViewEditor.tsx b/frontend/src/app/pages/Views/ViewEditor.tsx index 1713d805..9f9c47f5 100644 --- a/frontend/src/app/pages/Views/ViewEditor.tsx +++ b/frontend/src/app/pages/Views/ViewEditor.tsx @@ -505,6 +505,18 @@ const ViewEditor: React.FC = ({ output, onClose }) => { const [saveStatus, setSaveStatus] = useState<'idle' | 'unsaved' | 'saving' | 'saved'>('idle'); const autoSaveTimerRef = useRef | null>(null); const savedStatusTimerRef = useRef | null>(null); + // Skip preview reloads when nothing the user can SEE changed. + // The iframe renders index.html; if a save only touched SKILL.md or + // other non-rendered files, there's no point reloading the iframe — + // the visible content is identical and we'd just flash the empty + // "Ready" placeholder during the reload-blank-moment. Tracking the + // last reloaded snapshot of index.html lets us short-circuit those. + // Combined with the trailing-edge debounce below, the iframe only + // reloads when (a) index.html actually changed AND (b) the agent + // has stopped writing for >600ms — usually 0-1 reloads per generation. + const previewReloadTimerRef = useRef | null>(null); + const lastReloadedIndexHtmlRef = useRef(initialFiles['index.html'] ?? ''); + const PREVIEW_RELOAD_DEBOUNCE_MS = 600; const savingRef = useRef(false); const [executeResult, setExecuteResult] = useState(null); const [showConsole, setShowConsole] = useState(false); @@ -879,8 +891,25 @@ const ViewEditor: React.FC = ({ output, onClose }) => { setSaveStatus('saved'); if (savedStatusTimerRef.current) clearTimeout(savedStatusTimerRef.current); savedStatusTimerRef.current = setTimeout(() => setSaveStatus('idle'), 3000); - if (close) onClose(); - else previewRef.current?.reload(); + if (close) { + onClose(); + } else { + // Trailing-edge debounce + content-changed gate. Only triggers + // a real iframe reload when the agent has gone quiet AND the + // file the iframe actually renders (index.html) changed since + // the last reload. Eliminates the "Ready" empty-state flash + // entirely for non-rendered file writes (SKILL.md, etc). + if (previewReloadTimerRef.current) { + clearTimeout(previewReloadTimerRef.current); + } + previewReloadTimerRef.current = setTimeout(() => { + previewReloadTimerRef.current = null; + const currentHtml = files['index.html'] ?? ''; + if (currentHtml === lastReloadedIndexHtmlRef.current) return; + lastReloadedIndexHtmlRef.current = currentHtml; + previewRef.current?.reload(); + }, PREVIEW_RELOAD_DEBOUNCE_MS); + } captureThumbnailAsync(savedId); } catch (err: any) { console.error('Failed to save output:', err); @@ -1141,6 +1170,7 @@ const ViewEditor: React.FC = ({ output, onClose }) => { return () => { if (autoSaveTimerRef.current) clearTimeout(autoSaveTimerRef.current); if (savedStatusTimerRef.current) clearTimeout(savedStatusTimerRef.current); + if (previewReloadTimerRef.current) clearTimeout(previewReloadTimerRef.current); wsPushTimers.current.forEach(t => clearTimeout(t)); }; }, []); diff --git a/frontend/src/app/pages/Views/ViewPreview.tsx b/frontend/src/app/pages/Views/ViewPreview.tsx index 62aa935e..ff07e00e 100644 --- a/frontend/src/app/pages/Views/ViewPreview.tsx +++ b/frontend/src/app/pages/Views/ViewPreview.tsx @@ -161,7 +161,15 @@ const ViewPreview = forwardRef(({ >