mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-26 06:22:22 +02:00
[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:
@@ -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',
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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));
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user