[eric] onboarding fixes — auto fit-to-view before drag select, scroll-aware move_to, robust celebration timer, no-flash app preview, openrouter / custom model integration bug fixes (windows)

This commit is contained in:
ciregenz
2026-05-10 11:06:04 -07:00
parent 89cbfe9749
commit 443327d4bd
8 changed files with 129 additions and 31 deletions
@@ -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<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.
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 (
<Box sx={{ px: 1.6, pt: 1.6, pb: 1.6 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.8 }}>
@@ -282,30 +282,45 @@ async function runOp(op: ACOp, ctx: RunContext): Promise<void> {
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);
@@ -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).
@@ -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',
+10 -2
View File
@@ -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());
+32 -2
View File
@@ -505,6 +505,18 @@ const ViewEditor: React.FC<Props> = ({ output, onClose }) => {
const [saveStatus, setSaveStatus] = useState<'idle' | 'unsaved' | 'saving' | 'saved'>('idle');
const autoSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const savedStatusTimerRef = useRef<ReturnType<typeof setTimeout> | 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<ReturnType<typeof setTimeout> | null>(null);
const lastReloadedIndexHtmlRef = useRef<string>(initialFiles['index.html'] ?? '');
const PREVIEW_RELOAD_DEBOUNCE_MS = 600;
const savingRef = useRef(false);
const [executeResult, setExecuteResult] = useState<OutputExecuteResult | null>(null);
const [showConsole, setShowConsole] = useState(false);
@@ -879,8 +891,25 @@ const ViewEditor: React.FC<Props> = ({ 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<Props> = ({ 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));
};
}, []);
+9 -1
View File
@@ -161,7 +161,15 @@ const ViewPreview = forwardRef<ViewPreviewHandle, Props>(({
>
<iframe
ref={iframeRef}
key={iframeSrc ? `url-${reloadKey}` : 'srcdoc'}
// Key stable across reloads — only changes when switching MODES
// (URL vs srcdoc). Previously the key embedded reloadKey, which
// unmounted-and-remounted the iframe on every reload, producing
// a visible blank flash mid-burst. With a stable key, reloadKey
// still updates iframeSrc → React swaps the src attribute on
// the EXISTING iframe element → browser navigates in place,
// keeping the prior frame's pixels visible until the new doc
// paints. No flash.
key={iframeSrc ? 'url-mode' : 'srcdoc'}
src={iframeSrc}
sandbox="allow-scripts allow-same-origin"
style={{
+12 -1
View File
@@ -251,7 +251,18 @@ const settingsSlice = createSlice({
.addCase(fetchSettings.fulfilled, (state, action) => {
state.loading = false;
state.loaded = true;
state.data = action.payload;
// Belt-and-suspenders: skip the assignment when the payload is
// byte-identical to what we already have. The SignInGate's 2s poll
// would otherwise flip the `state.data` reference on every tick,
// re-running every effect that depends on `s.settings.data` —
// including form-sync useEffects elsewhere in the tree. Cheap on
// a small object, prevents an entire class of "polling wipes my
// form" bugs without needing every consumer to be defensive.
const next = JSON.stringify(action.payload);
const prev = JSON.stringify(state.data);
if (next !== prev) {
state.data = action.payload;
}
})
.addCase(fetchSettings.rejected, (state) => {
state.loading = false;