feat(bots): add bot-widget loading bar with cycle-complete hide and sync-state deference, plus matching cycle-complete polish on SyncIndicator

This commit is contained in:
heaven 2026-05-06 22:11:42 +03:00
parent 17ba496b7e
commit 97a50e29f9
5 changed files with 335 additions and 43 deletions

View file

@ -33,6 +33,7 @@ export type BotWidgetEmbedOptions = {
theme: Theme;
language: string;
onError: (error: Error) => void;
onReady?: () => void;
};
const getBotWidgetId = (preset: BotPreset): string => `vojo-bot-${preset.id}`;
@ -157,6 +158,18 @@ export class BotWidgetEmbed {
// demote the widget into the chat fallback even though the widget is fine.
private isReady = false;
// Two halves of «widget is fully booted». `iframeLoaded` flips on the
// iframe's `load` event (document complete + same-origin assets ready);
// `handshakeReady` flips on the ClientWidgetApi `ready` event (host
// capability handshake complete). The external `onReady` callback only
// fires once BOTH have happened — defensive coupling so a same-origin
// synchronous handshake (cached bundle) can't beat the document-complete
// signal and trigger a premature loading-bar fade. In practice load
// tends to fire first, but the order is not guaranteed by the spec.
private iframeLoaded = false;
private handshakeReady = false;
private readonly disposables: Array<() => void> = [];
// Expected origin of the widget iframe — captured once at construction
@ -284,6 +297,26 @@ export class BotWidgetEmbed {
const iframe = createBotIframe(preset);
container.append(iframe);
// External onReady fires only when BOTH the iframe document is fully
// loaded AND the ClientWidgetApi handshake is complete — see
// `iframeLoaded` / `handshakeReady` field comments. The host gate this
// serves is the loading-bar overlay in BotWidgetMount.
const fireExternalReady = () => {
if (this.iframeLoaded && this.handshakeReady) {
this.options.onReady?.();
}
};
// Attach the iframe load listener BEFORE the src assignment for the
// same reason ClientWidgetApi attaches its internal listener early —
// a cached same-origin document can finish loading before src returns
// control here, and a late listener would miss the load event.
const onIframeLoad = () => {
this.iframeLoaded = true;
fireExternalReady();
};
iframe.addEventListener('load', onIframeLoad);
const driver: WidgetDriver = new BotWidgetDriver(mx, room.roomId, preset);
const api = new ClientWidgetApi(widget, iframe, driver);
@ -296,8 +329,10 @@ export class BotWidgetEmbed {
const onReady = () => {
this.isReady = true;
this.handshakeReady = true;
// eslint-disable-next-line no-console
console.info('[BotWidget] handshake complete');
fireExternalReady();
};
// Element-Web treats `error:preparing` as informational — it never tears
// down the widget. Vojo follows the same rule: log the upstream cause and
@ -325,6 +360,7 @@ export class BotWidgetEmbed {
window.addEventListener('message', this.onWidgetMessage);
this.disposables.push(
() => iframe.removeEventListener('load', onIframeLoad),
() => api.off('ready', onReady),
() => api.off('error:preparing', onPreparingError),
() => iframe.removeEventListener('error', onIframeError),

View file

@ -1,28 +1,65 @@
import { style } from '@vanilla-extract/css';
import { DefaultReset, color, config, toRem } from 'folds';
export const Host = style([
DefaultReset,
{
position: 'relative',
width: '100%',
height: '100%',
backgroundColor: color.Background.Container,
},
]);
import { keyframes, style } from '@vanilla-extract/css';
export const FrameMount = style({
width: '100%',
height: '100%',
});
export const Toolbar = style([
DefaultReset,
{
position: 'absolute',
top: config.space.S300,
right: config.space.S300,
zIndex: 1,
maxWidth: `calc(100% - ${toRem(24)})`,
// Loading bar overlay shown while the widget iframe boots and capability
// handshake completes. Mirrors `SyncIndicator` (sweep + radial fade + glow)
// but pinned to the TOP of the iframe frame (under the BotShell hero) and
// painted in the brand fleet-violet `Primary.Main` (#9580ff) instead of the
// sync-state green. Resolves the «empty page on bot click» complaint:
// before this, the iframe took ~200-800ms to first paint and the user saw
// only the hero on a flat dark surface. Hex is hardcoded for the same
// reason `BotShell.HeroAvatar` hardcodes it: keep the bot accent stable
// across folds palette swaps.
const slide = keyframes({
'0%': { transform: 'translateX(-100%)' },
'100%': { transform: 'translateX(100%)' },
});
// Container clips horizontally so the slide-keyframe wraps invisibly off-
// screen at the edges. Vertically the upper drop-shadow halo (-6px..0) IS
// intentionally clipped — the bar sits flush against the BotShell hero's
// bottom border and a halo glowing UP would visually bleed into the hero
// chrome. Only the lower halo (0..8px) renders, glowing DOWN into the
// iframe — mirror of SyncIndicator's bottom-anchored bar where the halo
// renders UP into the page. Don't bump `top` to surface the upper halo;
// that would float the bar away from the hero edge and break the seam.
export const LoadingBarRoot = style({
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: '14px',
pointerEvents: 'none',
// Above the iframe, but no need for the global Z400 — local stacking
// context inside Frame is enough.
zIndex: 2,
overflow: 'hidden',
});
export const LoadingBar = style({
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '2px',
background:
'radial-gradient(ellipse 50% 100% at center, #9580ff 0%, rgba(149, 128, 255, 0.45) 35%, rgba(149, 128, 255, 0) 70%)',
filter: 'drop-shadow(0 0 6px rgba(149, 128, 255, 0.75))',
animation: `${slide} 1.6s linear infinite`,
willChange: 'transform',
// 250ms opacity transition matches SyncIndicator's crossfade so the bar
// doesn't pop in/out abruptly when ready flips. animationPlayState is
// toggled inline at the call site so the compositor doesn't keep
// sweeping while the bar is invisible (opacity:0 alone doesn't pause
// CSS animations — verified across browsers).
transition: 'opacity 250ms ease',
'@media': {
'(prefers-reduced-motion: reduce)': {
animation: 'none',
},
},
]);
});

View file

@ -1,9 +1,27 @@
import React, { useRef } from 'react';
import { Room } from 'matrix-js-sdk';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { Room, SyncState } from 'matrix-js-sdk';
import type { BotPreset } from './catalog';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useSyncState } from '../../hooks/useSyncState';
import { useBotWidgetEmbed } from './useBotWidgetEmbed';
import * as css from './BotWidgetMount.css';
// Anti-flicker debounce — same rationale as `SyncIndicator`'s
// `PROGRESS_FADE_IN_DELAY_MS`. On a warm/cached widget the iframe can
// boot in <100ms; without the delay the bar would briefly appear and
// fade out, reading as a flash. 100ms is just above the typical instant-
// load envelope yet below the perception threshold for «I clicked and
// nothing happened».
const LOADING_BAR_FADE_IN_DELAY_MS = 100;
// Fallback hide deadline if `animationiteration` never fires — covers
// any compositor freeze that drops the event (e.g. tab backgrounded
// mid-cycle and just foregrounded). 2000ms gives ~400ms slack over the
// 1.6s slide-cycle: a fast 60Hz display fires iteration ~16-32ms after
// cycle end, but a backgrounded compositor can be 100+ms late. The
// reduced-motion path skips this branch entirely.
const LOADING_BAR_HIDE_FALLBACK_MS = 2000;
// Renders the iframe-mount target and wires up `useBotWidgetEmbed` to
// drive the iframe lifecycle. Renamed from BotWidgetHost in the BotShell
// refactor — «Host» was overloaded with BotExperienceHost (page-level
@ -16,7 +34,130 @@ type BotWidgetMountProps = {
export function BotWidgetMount({ preset, room, onError }: BotWidgetMountProps) {
const containerRef = useRef<HTMLDivElement>(null);
useBotWidgetEmbed({ containerRef, preset, room, onError });
const { ready } = useBotWidgetEmbed({ containerRef, preset, room, onError });
return <div ref={containerRef} className={css.FrameMount} />;
// Track Matrix sync state so the bot loading bar yields to the global
// SyncIndicator when the connection is unhealthy. Without this, on a
// dropped network the user would see TWO sweeping bars at once — the
// bot bar at top stuck in «still loading» plus the SyncIndicator at
// bottom in transient/error state. The bottom bar is the canonical
// connection-state surface; the top one defers.
const mx = useMatrixClient();
const [syncState, setSyncState] = useState<SyncState | null>(() => mx.getSyncState());
useSyncState(
mx,
useCallback((state: SyncState) => {
setSyncState(state);
}, [])
);
useEffect(() => {
// Close the render-to-effect gap (same pattern as SyncIndicator) so
// a Sync event fired between useState init and listener attach
// doesn't get dropped.
setSyncState(mx.getSyncState());
}, [mx]);
// Healthy = SyncIndicator's «hidden» branch (`Syncing` or `Stopped`).
// Anything else (`Reconnecting`, `Catchup`, `Error`, initial null)
// means the bottom bar will be visible, so we should not also show
// ours. Mirrors `stateToVisual` in SyncIndicator.tsx.
const syncHealthy = syncState === SyncState.Syncing || syncState === SyncState.Stopped;
// Three logical phases for the loading bar:
// - hidden (visible=false, pendingHide=false) → initial / post-hide
// - visible (visible=true, pendingHide=false) → animating actively
// - closing (visible=true, pendingHide=true) → ready fired mid-
// cycle, waiting for
// animationiteration
// to land at the
// cycle boundary
// before snapping
// invisible
// The closing → hidden transition is driven by the iteration event so
// the user always sees a complete left-to-right sweep and never a
// mid-stroke cut. A fallback timer covers the case where the event
// can't fire (compositor freeze on a backgrounded tab). Reduced-motion
// and sync-unhealthy paths skip cycle-complete entirely — for the
// first there's no animation cycle to honour, for the second the
// bottom SyncIndicator should take over without UI overlap.
const [visible, setVisible] = useState(false);
const [pendingHide, setPendingHide] = useState(false);
// Reasons the bar should NOT be shown:
// - 'ready' — widget loaded normally, hide gracefully (cycle-complete)
// - 'sync' — connection unhealthy, snap-hide so the SyncIndicator's
// bar is the only visible chrome (no double-bar UX)
// - null — bar should be visible
// Order matters: `ready` takes precedence so a successfully-loaded
// widget that then drops its connection still gets the polished cycle
// hide on the original ready event rather than being re-classified as
// a sync-issue snap.
let hideReason: 'ready' | 'sync' | null = null;
if (ready) hideReason = 'ready';
else if (!syncHealthy) hideReason = 'sync';
useEffect(() => {
if (hideReason !== null) {
// Bar wasn't shown yet — cached fast load (or a handshake that
// landed during the debounce window) won the race. Nothing to do;
// bar stays hidden, no flash.
if (!visible) return undefined;
// Sync-issue snap: jump straight to invisible so the bottom
// SyncIndicator can take over without two bars overlapping.
// Reduced-motion: animation is off (no iterations ever land), so
// parking a static stripe for ~2s isn't graceful, just stuck.
if (
hideReason === 'sync' ||
window.matchMedia('(prefers-reduced-motion: reduce)').matches
) {
setVisible(false);
setPendingHide(false);
return undefined;
}
// hideReason === 'ready' with motion enabled — bar is mid-sweep.
// Defer the actual hide to the next cycle boundary. Fallback
// force-hides if the event never lands.
setPendingHide(true);
const fallback = setTimeout(() => {
setVisible(false);
setPendingHide(false);
}, LOADING_BAR_HIDE_FALLBACK_MS);
return () => clearTimeout(fallback);
}
// Bar should show — clear any leftover hide request from a previous
// ready-then-not-ready blip and (if not yet visible) schedule the
// debounced fade-in.
setPendingHide(false);
if (visible) return undefined;
const fadeIn = setTimeout(() => setVisible(true), LOADING_BAR_FADE_IN_DELAY_MS);
return () => clearTimeout(fadeIn);
}, [hideReason, visible]);
const handleIteration = () => {
if (pendingHide) {
setVisible(false);
setPendingHide(false);
}
};
// Loading bar stays mounted; opacity carries the visible/hidden
// crossfade. animationPlayState is paused while invisible so the
// compositor doesn't keep sweeping a 0-alpha element forever.
// pointer-events: none on the root so it never intercepts iframe
// clicks even mid-fade.
return (
<>
<div ref={containerRef} className={css.FrameMount} />
<div className={css.LoadingBarRoot} aria-hidden>
<div
className={css.LoadingBar}
style={{
opacity: visible ? 1 : 0,
animationPlayState: visible ? 'running' : 'paused',
}}
onAnimationIteration={handleIteration}
/>
</div>
</>
);
}

View file

@ -1,4 +1,4 @@
import { RefObject, useEffect, useRef } from 'react';
import { RefObject, useEffect, useRef, useState } from 'react';
import { Room } from 'matrix-js-sdk';
import { useTranslation } from 'react-i18next';
import { useMatrixClient } from '../../hooks/useMatrixClient';
@ -13,22 +13,29 @@ type UseBotWidgetEmbedOptions = {
onError: () => void;
};
type UseBotWidgetEmbedResult = {
// True once the widget's `ready` event has fired (capability handshake
// completed). Resets to false whenever the embed is recreated (room or
// preset change). Drives the loading-bar overlay in BotWidgetMount.
ready: boolean;
};
// Encapsulates the imperative BotWidgetEmbed lifecycle so the host React
// component stays small. Mirrors useCallEmbed's split: the hook owns the
// effect, the component owns the DOM ref + visual chrome.
//
// Returns a ref to the live embed instance; the host can use it to push
// runtime updates (e.g. theme) without forcing an iframe remount.
// effect, the component owns the DOM ref + visual chrome. Live embed
// instance is held in a private ref for runtime updates (theme
// propagation below); not exposed because no caller needs it today.
export const useBotWidgetEmbed = ({
containerRef,
preset,
room,
onError,
}: UseBotWidgetEmbedOptions): RefObject<BotWidgetEmbed | undefined> => {
}: UseBotWidgetEmbedOptions): UseBotWidgetEmbedResult => {
const { i18n } = useTranslation();
const mx = useMatrixClient();
const theme = useTheme();
const embedRef = useRef<BotWidgetEmbed>();
const [ready, setReady] = useState(false);
// Capture mount-time values in refs so the lifecycle effect doesn't re-run
// when locale/theme change — those are propagated separately via setTheme.
@ -50,6 +57,10 @@ export const useBotWidgetEmbed = ({
const container = containerRef.current;
if (!container) return undefined;
// Reset readiness whenever the embed is (re)created. Subsequent
// `ready` event from the new ClientWidgetApi flips this back to true.
setReady(false);
let embed: BotWidgetEmbed | undefined;
try {
embed = new BotWidgetEmbed({
@ -60,6 +71,7 @@ export const useBotWidgetEmbed = ({
theme: themeRef.current,
language: languageRef.current,
onError,
onReady: () => setReady(true),
});
embedRef.current = embed;
} catch (error) {
@ -95,5 +107,5 @@ export const useBotWidgetEmbed = ({
embedRef.current?.setTheme(theme).catch(() => undefined);
}, [theme]);
return embedRef;
return { ready };
};

View file

@ -4,11 +4,23 @@ import { ClientEvent, MatrixClient, SyncState } from 'matrix-js-sdk';
import { isAndroidPlatform } from '../../utils/capacitor';
import * as css from './SyncIndicator.css';
// Suppress 0-150ms green flashes during fast transient state cycles
// Suppress sub-100ms green flashes during fast transient state cycles
// (e.g. one /sync request blips Reconnecting then immediately recovers to
// Syncing — without the delay the bar would briefly appear at whatever
// position the slide animation happened to be at).
const PROGRESS_FADE_IN_DELAY_MS = 150;
// position the slide animation happened to be at). 100ms keeps the bar
// responsive enough that real transitions land before the user gives up.
//
// Trade-off: combined with the cycle-complete hide below, transients in
// the 100-1800ms window now block the bar for a full slide cycle (~2s
// of visible bar) instead of cutting at the actual recovery moment.
// Deliberate choice — graceful animation > strict timing accuracy.
const PROGRESS_FADE_IN_DELAY_MS = 100;
// Fallback hide deadline if `animationiteration` never fires — covers
// any compositor freeze that drops the event (e.g. tab backgrounded
// mid-cycle and just foregrounded). 2100ms gives ~300ms slack over the
// 1.8s slide-cycle. The reduced-motion path skips this branch entirely.
const PROGRESS_HIDE_FALLBACK_MS = 2100;
// On detected resume, hold the visual at "progress" (green) for this long if
// the SDK is in `Error`. Worst-case keep-alive backoff in matrix-js-sdk is
@ -142,17 +154,60 @@ export function SyncIndicator({ mx }: SyncIndicatorProps) {
const visual: Visual =
resumeGraceUntil > 0 && baseVisual === 'error' ? 'progress' : baseVisual;
// Anti-flicker debounce: the green layer only becomes visible if the
// progress visual is sustained for at least 150ms.
// Three logical phases for the green progress bar (mirrors the bot
// widget loading bar):
// - hidden (showProgress=false, pendingProgressHide=false)
// - visible (showProgress=true, pendingProgressHide=false)
// - closing (showProgress=true, pendingProgressHide=true) →
// progress→hidden landed mid-sweep, waiting for the
// animationiteration boundary to land before snapping
// invisible so the user always sees a complete left-to-
// right sweep, never a mid-stroke cut.
// Cycle-complete is only applied for progress→hidden. progress→error
// skips it because we want the red layer to take over without a half-
// faded green sweep visually mixing on top of it.
const [showProgress, setShowProgress] = useState(false);
const [pendingProgressHide, setPendingProgressHide] = useState(false);
useEffect(() => {
if (visual !== 'progress') {
setShowProgress(false);
return undefined;
if (visual === 'progress') {
setPendingProgressHide(false);
if (showProgress) return undefined;
const timer = setTimeout(() => setShowProgress(true), PROGRESS_FADE_IN_DELAY_MS);
return () => clearTimeout(timer);
}
const timer = setTimeout(() => setShowProgress(true), PROGRESS_FADE_IN_DELAY_MS);
return () => clearTimeout(timer);
}, [visual]);
if (visual === 'hidden' && showProgress) {
// Reduced-motion: animation is off, no iteration will land,
// parking a static green bar for ~2s isn't graceful — it's just
// a stuck bar. Snap invisible immediately.
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
setShowProgress(false);
setPendingProgressHide(false);
return undefined;
}
// Sync recovered while bar was mid-sweep — defer the hide to the
// next iteration boundary. Fallback force-hides if the event
// never lands (compositor freeze on backgrounded tab).
setPendingProgressHide(true);
const fallback = setTimeout(() => {
setShowProgress(false);
setPendingProgressHide(false);
}, PROGRESS_HIDE_FALLBACK_MS);
return () => clearTimeout(fallback);
}
// Either visual === 'error' (red layer takes over, drop green now)
// or visual === 'hidden' && !showProgress (already hidden, no-op).
setShowProgress(false);
setPendingProgressHide(false);
return undefined;
}, [visual, showProgress]);
const handleProgressIteration = () => {
if (pendingProgressHide) {
setShowProgress(false);
setPendingProgressHide(false);
}
};
const showError = visual === 'error';
@ -160,6 +215,15 @@ export function SyncIndicator({ mx }: SyncIndicatorProps) {
// 250ms transition (set on barBase). `animationPlayState: 'paused'` on
// the green layer when invisible avoids continuous compositor work —
// `opacity: 0` does NOT pause CSS animations in any major browser.
//
// On progress→error we snap the green to opacity:0 with no transition
// (`transition: 'none'`). The default 250ms fade-out would otherwise
// cross-fade with the red layer's 250ms fade-in — both stack at the
// same bottom edge, both at intermediate alpha, producing a muddy
// green-on-red blend at the exact moment we're trying to alarm the
// user. The `paused` animation also freezes the green at whatever
// mid-cycle position it was at, so the cross-fade looks like a static
// green smear bleeding into a static red bar. Snap-hide kills it.
return (
<div className={css.root} aria-hidden>
<div
@ -167,7 +231,9 @@ export function SyncIndicator({ mx }: SyncIndicatorProps) {
style={{
opacity: showProgress ? 1 : 0,
animationPlayState: showProgress ? 'running' : 'paused',
transition: showError ? 'none' : undefined,
}}
onAnimationIteration={handleProgressIteration}
/>
<div className={css.barError} style={{ opacity: showError ? 1 : 0 }} />
</div>