diff --git a/electron/package.json b/electron/package.json index 5aff0abf..ded67e03 100644 --- a/electron/package.json +++ b/electron/package.json @@ -1,6 +1,6 @@ { "name": "openswarm", - "version": "1.1.68", + "version": "1.1.69", "description": "OpenSwarm — AI Agent Orchestrator", "author": "openswarm-ai", "main": "main.js", diff --git a/frontend/src/app/components/Onboarding/_motionWin.tsx b/frontend/src/app/components/Onboarding/_motionWin.tsx index 882f137c..ddd90773 100644 --- a/frontend/src/app/components/Onboarding/_motionWin.tsx +++ b/frontend/src/app/components/Onboarding/_motionWin.tsx @@ -25,28 +25,39 @@ const stripFramerProps = (props: any) => { }; // Components that drive position via `animate={{ x, y }}` (ACPopup, ACMultiChoice, etc.) would otherwise lose their layout when the animate prop is stripped, because they have no fallback style.transform. We salvage the latest numeric x/y from animate and apply them as a transform so the div lands in the right place; no animation, just static placement. +// One cached component per tag. CRITICAL: without the cache the Proxy getter +// returns a NEW forwardRef component on every `motion.div` access, so React +// sees a different component type each render and REMOUNTS the DOM node every +// time. A freshly-mounted node has no previous transform to ease from, so CSS +// transitions never run (getAnimations() stays empty) and the cursor jumps +// instantly instead of gliding; the breathing pulse never animates either. +// Caching gives each tag a stable identity so React reconciles in place. +const tagComponentCache: Record = {}; const motionShim: any = new Proxy({}, { get: (_target, tag: string) => { - return React.forwardRef((props: any, ref: any) => { - let translate = ''; - const a = props.animate; - if (a && typeof a === 'object' && !Array.isArray(a)) { - const ax = typeof a.x === 'number' ? a.x : null; - const ay = typeof a.y === 'number' ? a.y : null; - if (ax !== null || ay !== null) { - translate = `translate(${ax ?? 0}px, ${ay ?? 0}px)`; + if (!tagComponentCache[tag]) { + tagComponentCache[tag] = React.forwardRef((props: any, ref: any) => { + let translate = ''; + const a = props.animate; + if (a && typeof a === 'object' && !Array.isArray(a)) { + const ax = typeof a.x === 'number' ? a.x : null; + const ay = typeof a.y === 'number' ? a.y : null; + if (ax !== null || ay !== null) { + translate = `translate(${ax ?? 0}px, ${ay ?? 0}px)`; + } } - } - const stripped = stripFramerProps(props); - if (translate) { - const existing = stripped.style && stripped.style.transform; - stripped.style = { - ...(stripped.style || {}), - transform: existing ? `${existing} ${translate}` : translate, - }; - } - return React.createElement(tag, { ...stripped, ref }); - }); + const stripped = stripFramerProps(props); + if (translate) { + const existing = stripped.style && stripped.style.transform; + stripped.style = { + ...(stripped.style || {}), + transform: existing ? `${existing} ${translate}` : translate, + }; + } + return React.createElement(tag, { ...stripped, ref }); + }); + } + return tagComponentCache[tag]; }, }); diff --git a/frontend/src/app/components/Onboarding/ac/AgenticCursor.tsx b/frontend/src/app/components/Onboarding/ac/AgenticCursor.tsx index a9ee52ca..212af63c 100644 --- a/frontend/src/app/components/Onboarding/ac/AgenticCursor.tsx +++ b/frontend/src/app/components/Onboarding/ac/AgenticCursor.tsx @@ -56,6 +56,12 @@ const SPRING = { type: 'spring' as const, stiffness: 260, damping: 26 }; // On Windows the motionWin shim strips Framer Motion's animate prop, so controls.set({x,y}) never moves the wrapper. We bypass by reading the same store the popups read and applying style.transform directly; Mac is unaffected since Framer's own transform writes win the cascade. const IS_WIN = typeof navigator !== 'undefined' && navigator.userAgent.includes('Windows'); +// Windows fallback-ease duration. Mirrors the CSS `transform 420ms` transition +// on the cursor wrapper below; on Windows the Director holds for this (plus a +// small settle margin) after a moveTo/fadeOut so the CSS ease actually plays +// before the next step's instant write lands. +const WIN_EASE_MS = 420; + const AgenticCursor = forwardRef((_props, ref) => { const c = useClaudeTokens(); const controls = useAnimationControls(); @@ -100,7 +106,22 @@ const AgenticCursor = forwardRef((_props, ref) => { async moveTo(x, y, transition) { // Stop prior tracker so it doesn't snap the cursor back to its old anchor mid-animation. stopTrackingInternal(); - // Order matters and is identical to the pre-Windows version on Mac: Framer's spring drives the popup via onUpdate during the animation, then writePos confirms the final position. On Windows controls.start is the shim no-op so this resolves instantly and writePos (instant=false) hands the CSS transition the target to ease toward. + if (IS_WIN) { + // Windows has no Framer runtime (controls.start is a no-op); the visual + // hop is the CSS transition on the wrapper, driven by cursorStore. + // TWO-STEP so Chromium actually animates: (1) commit the eased + // transition at the CURRENT position (cursorStore now flushes an + // instant-change), let it paint, then (2) move. Changing transform in + // the same recalc that flips transition none->420ms makes Chromium + // apply the move instantly (teleport). Then HOLD for the ease so the + // Director doesn't begin the next step mid-glide. + writePos(posRef.current.x, posRef.current.y, true, false); + await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))); + writePos(x, y, true, false); + await new Promise((r) => setTimeout(r, WIN_EASE_MS + 30)); + return; + } + // Mac path, byte-identical to the pre-Windows version: Framer's spring drives the popup via onUpdate during the animation, then writePos confirms the final position. await controls.start({ x, y, @@ -110,6 +131,18 @@ const AgenticCursor = forwardRef((_props, ref) => { }, async fadeOut(to) { stopTrackingInternal(); + if (IS_WIN) { + // Glide to the exit point via the same two-step arm as moveTo so the + // CSS ease actually runs, then hide. The opacity fade has no Framer + // runtime on Windows, so the cursor just disappears once it eases to `to`. + writePos(posRef.current.x, posRef.current.y, true, false); + await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))); + writePos(to.x, to.y, true, false); + await new Promise((r) => setTimeout(r, WIN_EASE_MS + 30)); + cursorStore.set({ visible: false }); + setVisible(false); + return; + } await controls.start({ x: to.x, y: to.y, transition: SPRING }); writePos(to.x, to.y, true, false); await controls.start({ diff --git a/frontend/src/app/components/Onboarding/ac/cursorStore.ts b/frontend/src/app/components/Onboarding/ac/cursorStore.ts index c24fb575..f8109b5d 100644 --- a/frontend/src/app/components/Onboarding/ac/cursorStore.ts +++ b/frontend/src/app/components/Onboarding/ac/cursorStore.ts @@ -33,11 +33,18 @@ export const cursorStore = { // Visibility transitions bypass coalescing (mounts/unmounts must flush immediately). const visibilityChanged = merged.visible !== state.visible; + // `instant` flips the Windows CSS-transition mode (snap vs ease). Commit it + // immediately, like visibility, so moveTo can arm the eased transition a + // paint BEFORE it moves the cursor: a same-position arm write is otherwise + // coalesced silently here, so the move and the none->420ms transition flip + // land in one recalc and Chromium renders it as an instant jump. No-op on + // Mac (the wrapper there is Framer-driven and ignores `instant`). + const instantChanged = merged.instant !== state.instant; const dx = Math.abs(merged.x - state.x); const dy = Math.abs(merged.y - state.y); const significantMove = dx >= COALESCE_PX || dy >= COALESCE_PX; - if (visibilityChanged) { + if (visibilityChanged || instantChanged) { state = merged; pendingState = null; rafScheduled = false; diff --git a/frontend/src/app/pages/Dashboard/DashboardToolbar.tsx b/frontend/src/app/pages/Dashboard/DashboardToolbar.tsx index b14400f5..7ca528ed 100644 --- a/frontend/src/app/pages/Dashboard/DashboardToolbar.tsx +++ b/frontend/src/app/pages/Dashboard/DashboardToolbar.tsx @@ -405,65 +405,6 @@ const DashboardToolbar = React.forwardRef( return ( <> - {(inputOpen || historyOpen) && ( - // Image #54: paired mode pills above the composer/popover. - // The two states are mutually exclusive: opening one closes the - // other so the body underneath only renders one thing at a time. - - { - if (historyOpen) { - handleCloseHistory(); - onNewAgent(); - } - // If already in inputOpen, this is a no-op (we're already - // in new chat). The visible active styling tells the user - // that. Clicking again does nothing intentionally. - }} - role="button" - sx={{ - display: 'inline-flex', alignItems: 'center', gap: 0.3, - fontSize: '0.74rem', fontWeight: 600, - color: c.text.primary, - bgcolor: c.bg.surface, - border: `1px solid ${inputOpen && !historyOpen ? c.border.medium : c.border.subtle}`, - boxShadow: inputOpen && !historyOpen ? c.shadow.sm : 'none', - px: 0.85, py: 0.3, borderRadius: 999, - cursor: historyOpen ? 'pointer' : 'default', - '&:hover': historyOpen ? { bgcolor: c.bg.elevated } : {}, - }}> - - New Chat - - { - if (historyOpen) { - handleCloseHistory(); - return; - } - // Close the composer first; inputOpen takes precedence in - // the render branch below so the popover would be hidden - // behind it otherwise. - if (inputOpen) onCancel(); - setHistoryOpen(true); - }} - role="button" - sx={{ - display: 'inline-flex', alignItems: 'center', gap: 0.3, - fontSize: '0.74rem', fontWeight: 600, - color: historyOpen ? c.text.primary : c.text.secondary, - bgcolor: c.bg.surface, - border: `1px solid ${historyOpen ? c.border.medium : c.border.subtle}`, - boxShadow: historyOpen ? c.shadow.sm : 'none', - px: 0.85, py: 0.3, borderRadius: 999, - cursor: 'pointer', - '&:hover': { bgcolor: c.bg.elevated }, - }}> - - History - - - )}