diff --git a/CHANGELOG.md b/CHANGELOG.md
index eaac76e904..1078d41061 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,12 +11,16 @@ and this project adheres to
### Changed
- 🔥(frontend) drop unused vendored ConnectionObserver
+- 🐛(frontend) vendor formatChatMessageLinks and trim surrounding newlines
### Fixed
- 📈(frontend) downgrade unreachable external home URL from error to event
- 🐛(frontend) handle 401 responses when syncing user preferences
- 🐛(frontend) harden speaker test against missing sinks and play errors
+- 🐛(frontend) implement hysteresis band for the control bar layout
+- 🐛(frontend) fix toolbar ResizeObserver loop and alignment drift
+- 🐛(analytics) filter benign ResizeObserver loop error in Sentry/PostHog
## [1.26.0] - 2026-08-12
diff --git a/src/frontend/src/features/analytics/exceptionFilters.ts b/src/frontend/src/features/analytics/exceptionFilters.ts
new file mode 100644
index 0000000000..9a11cfe905
--- /dev/null
+++ b/src/frontend/src/features/analytics/exceptionFilters.ts
@@ -0,0 +1,24 @@
+import type { CaptureResult } from 'posthog-js'
+
+const IGNORED_EXCEPTION_PATTERNS = [
+ /ResizeObserver loop (completed with undelivered notifications|limit exceeded)/,
+]
+
+const shouldIgnoreException = (value: unknown): boolean =>
+ typeof value === 'string' &&
+ IGNORED_EXCEPTION_PATTERNS.some((pattern) => pattern.test(value))
+
+export const filterExceptions = (
+ event: CaptureResult | null
+): CaptureResult | null => {
+ if (event?.event !== '$exception') return event
+
+ const exceptionList = event.properties?.['$exception_list']
+ const values: unknown[] = Array.isArray(exceptionList)
+ ? exceptionList.map((exception) => exception?.value)
+ : []
+
+ values.push(event.properties?.['$exception_message'])
+
+ return values.some(shouldIgnoreException) ? null : event
+}
diff --git a/src/frontend/src/features/analytics/hooks/useAnalytics.ts b/src/frontend/src/features/analytics/hooks/useAnalytics.ts
index a1c2d37241..1729bea28e 100644
--- a/src/frontend/src/features/analytics/hooks/useAnalytics.ts
+++ b/src/frontend/src/features/analytics/hooks/useAnalytics.ts
@@ -2,6 +2,7 @@ import { useEffect } from 'react'
import { type ApiUser } from '@/features/auth/api/ApiUser'
import { useUser } from '@/features/auth/api/useUser'
import { getPosthog } from '../utils'
+import { filterExceptions } from '../exceptionFilters'
export const startAnalyticsSession = (data: ApiUser) => {
getPosthog().then((ph) => {
@@ -47,6 +48,7 @@ export const useAnalytics = ({
capture_unhandled_rejections: true,
capture_console_errors: true,
},
+ before_send: filterExceptions,
})
})
}, [id, host, flags_api_host, isDisabled])
diff --git a/src/frontend/src/features/chat/components/ChatMessageBody.tsx b/src/frontend/src/features/chat/components/ChatMessageBody.tsx
index 67381ea4d6..f9decfdcaa 100644
--- a/src/frontend/src/features/chat/components/ChatMessageBody.tsx
+++ b/src/frontend/src/features/chat/components/ChatMessageBody.tsx
@@ -1,6 +1,6 @@
import { ChatRow } from '@/stores/chat'
import React, { useMemo } from 'react'
-import { formatChatMessageLinks } from '@livekit/components-react'
+import { formatChatMessageLinks } from '../utils'
import { css } from '@/styled-system/css'
import { Text } from '@/primitives'
diff --git a/src/frontend/src/features/chat/utils.tsx b/src/frontend/src/features/chat/utils.tsx
new file mode 100644
index 0000000000..7542e3b1bf
--- /dev/null
+++ b/src/frontend/src/features/chat/utils.tsx
@@ -0,0 +1,32 @@
+import { tokenize, createDefaultGrammar } from '@livekit/components-core'
+import { ReactNode } from 'react'
+
+const defaultGrammar = Object.freeze(createDefaultGrammar())
+
+export function formatChatMessageLinks(message: string): ReactNode {
+ const trimmedMessage = message.replace(/^[\r\n]+|[\r\n]+$/g, '')
+ return tokenize(trimmedMessage, defaultGrammar).map((tok, i) => {
+ if (typeof tok === `string`) {
+ return tok
+ } else {
+ const content = tok.content.toString()
+ const href =
+ tok.type === `url`
+ ? /^http(s?):\/\//.test(content)
+ ? content
+ : `https://${content}`
+ : `mailto:${content}`
+ return (
+
+ {content}
+
+ )
+ }
+ })
+}
diff --git a/src/frontend/src/features/reactions/components/toolbar/ReactionButtonsContainer.tsx b/src/frontend/src/features/reactions/components/toolbar/ReactionButtonsContainer.tsx
index 07e91a5f9c..acfb484b04 100644
--- a/src/frontend/src/features/reactions/components/toolbar/ReactionButtonsContainer.tsx
+++ b/src/frontend/src/features/reactions/components/toolbar/ReactionButtonsContainer.tsx
@@ -26,8 +26,8 @@ const StyledContainer = styled('div', {
backgroundColor: 'primaryDark.100',
maxWidth: '100%',
opacity: 0,
- transform: 'translateY(3.25rem)',
- transition: 'opacity, transform',
+ translate: '0 3.25rem',
+ transition: 'opacity, translate',
transitionDuration: '0.5s',
transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)',
pointerEvents: 'none',
@@ -36,7 +36,7 @@ const StyledContainer = styled('div', {
isVisible: {
true: {
opacity: 1,
- transform: 'translateY(0)',
+ translate: '0 0',
pointerEvents: 'auto',
},
},
@@ -84,7 +84,7 @@ export const ReactionButtonsContainer = ({
shouldBeCenteredWithToggleButton,
setShouldBeCenteredWithToggleButton,
] = useState(false)
- const [rightOffset, setRightOffset] = useState(0)
+ const [offsetX, setOffsetX] = useState(0)
const updateArrows = useCallback(() => {
const el = scrollRef.current
@@ -115,7 +115,7 @@ export const ReactionButtonsContainer = ({
useLayoutEffect(() => {
if (!shouldBeCenteredWithToggleButton || isMobile) {
- setRightOffset(0)
+ setOffsetX(0)
return
}
@@ -133,7 +133,7 @@ export const ReactionButtonsContainer = ({
const containerCenterX = containerRect.left + containerRect.width / 2
const shift = toggleCenterX - containerCenterX
if (Math.abs(shift) < 0.5) return
- setRightOffset((prev) => prev - shift * 2)
+ setOffsetX((prev) => prev + shift)
}
const schedule = () => {
@@ -182,7 +182,7 @@ export const ReactionButtonsContainer = ({
isVisible={isVisible}
style={
shouldBeCenteredWithToggleButton && !isMobile && adjustedCentering
- ? { marginRight: `${rightOffset}px` }
+ ? { transform: `translateX(${offsetX}px)` }
: { margin: '0 15px' }
}
>
diff --git a/src/frontend/src/features/rooms/livekit/prefabs/ControlBar/MoreOptions.tsx b/src/frontend/src/features/rooms/livekit/prefabs/ControlBar/MoreOptions.tsx
index 582d08c158..d7b5e064af 100644
--- a/src/frontend/src/features/rooms/livekit/prefabs/ControlBar/MoreOptions.tsx
+++ b/src/frontend/src/features/rooms/livekit/prefabs/ControlBar/MoreOptions.tsx
@@ -12,7 +12,8 @@ import type { ToggleButtonProps } from '@/primitives/ToggleButton'
import { RiArrowDownSLine, RiArrowUpSLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
-const CONTROL_BAR_BREAKPOINT = 1100
+const CONTROL_BAR_BREAKPOINT_WIDE = 1100
+const CONTROL_BAR_BREAKPOINT_NARROW = 1050
const NavigationControls = ({
onPress,
@@ -65,10 +66,9 @@ export const LateralMenu = () => {
)
}
-
interface BreakpointObserverProps {
containerRef: RefObject
- onWideChange: (isWide: boolean) => void
+ onWideChange: (isWide: boolean | null) => void
}
const BreakpointObserver = ({
@@ -76,7 +76,20 @@ const BreakpointObserver = ({
onWideChange,
}: BreakpointObserverProps) => {
const { width } = useSize(containerRef)
- const isWide = width > CONTROL_BAR_BREAKPOINT
+ const [isWide, setIsWide] = useState(null)
+
+ useEffect(() => {
+ if (!width) {
+ return
+ }
+ if (width > CONTROL_BAR_BREAKPOINT_WIDE) {
+ setIsWide(true)
+ } else if (width <= CONTROL_BAR_BREAKPOINT_NARROW) {
+ setIsWide(false)
+ } else {
+ setIsWide((prev) => (prev === null ? false : prev))
+ }
+ }, [width])
useEffect(() => {
onWideChange(isWide)
@@ -90,7 +103,7 @@ export const MoreOptions = ({
}: {
parentElement: RefObject
}) => {
- const [isWide, setIsWide] = useState(false)
+ const [isWide, setIsWide] = useState(null)
return (
- {isWide ? : }
+ {isWide !== null && (isWide ? : )}
)
}