fix(bots): AI typing indicator — 45s receipt expiry (5s died mid-generation, Synapse doesn't re-emit unchanged m.typing); avatar ring spinner in header replaces dots

This commit is contained in:
heaven 2026-07-03 00:04:34 +03:00
parent 0a538a9e0b
commit bbf2e55165
5 changed files with 73 additions and 65 deletions

View file

@ -0,0 +1,39 @@
import { keyframes, style } from '@vanilla-extract/css';
import { toRem } from 'folds';
// "Bot is thinking": an accent arc spinning along the OUTER edge of the hero avatar
// while the bot composes a reply. Lives in its own file (not BotShell.css) because the
// hero styles are shared verbatim with bridge bots — only the AI chat gets the ring.
// The wrapper exists because HeroAvatar clips (overflow: hidden), so an overhanging
// ring must be its sibling, not its child.
const vojoAiAccent = '#f35294'; // the Vojo AI avatar's own accent (mean of its pink cluster)
const spin = keyframes({
to: { transform: 'rotate(360deg)' },
});
export const AvatarSpinHost = style({
position: 'relative',
flexShrink: 0, // takes over HeroAvatar's role in the hero flex row
});
export const AvatarSpinRing = style({
position: 'absolute',
inset: toRem(-4), // a hair outside the 56px avatar circle — a ring around it, not a border on it
borderRadius: '50%',
border: `${toRem(2)} solid transparent`,
borderTopColor: vojoAiAccent,
animationName: spin,
animationDuration: '1.6s',
animationTimingFunction: 'linear',
animationIterationCount: 'infinite',
pointerEvents: 'none',
'@media': {
'(prefers-reduced-motion: reduce)': {
// Motion off: a static full ring still says "working".
animationName: 'none',
borderColor: vojoAiAccent,
opacity: 0.6,
},
},
});

View file

@ -11,11 +11,13 @@ import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { stopPropagation } from '../../utils/keyboard'; import { stopPropagation } from '../../utils/keyboard';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useRoomTypingMember } from '../../hooks/useRoomTypingMembers';
import { mxcUrlToHttp } from '../../utils/matrix'; import { mxcUrlToHttp } from '../../utils/matrix';
// Reuse the bridge hero's EXACT styles so the AI header is pixel-identical to every other bot // Reuse the bridge hero's EXACT styles so the AI header is pixel-identical to every other bot
// header (avatar 56px, name 22px, handle, description, mobile back chevron). The hero markup // header (avatar 56px, name 22px, handle, description, mobile back chevron). The hero markup
// below mirrors BotShellHero verbatim — keep them in sync. Only the ⋮ menu differs (AiChatMenu). // below mirrors BotShellHero verbatim — keep them in sync. Only the ⋮ menu differs (AiChatMenu).
import * as css from './BotShell.css'; import * as css from './BotShell.css';
import * as aiCss from './AiChatHeader.css';
// Initial for the avatar block — same rule as BotShellHero. // Initial for the avatar block — same rule as BotShellHero.
const heroInitial = (preset: BotPreset): string => { const heroInitial = (preset: BotPreset): string => {
@ -50,6 +52,11 @@ export function AiChatHeader({ preset, room, onNewChat, onOpenHistory }: AiChatH
? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined ? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined
: undefined; : undefined;
// "Bot is thinking" — the bot's typing receipt drives the orange ring around the hero
// avatar (the AI chat's only generation indicator; the thread body shows nothing).
const typingReceipts = useRoomTypingMember(room.roomId);
const botTyping = typingReceipts.some((r) => r.userId === preset.mxid);
const handleOpenMenu: React.MouseEventHandler<HTMLButtonElement> = (evt) => { const handleOpenMenu: React.MouseEventHandler<HTMLButtonElement> = (evt) => {
setMenuAnchor(evt.currentTarget.getBoundingClientRect()); setMenuAnchor(evt.currentTarget.getBoundingClientRect());
}; };
@ -79,8 +86,15 @@ export function AiChatHeader({ preset, room, onNewChat, onOpenHistory }: AiChatH
)} )}
</BackRouteHandler> </BackRouteHandler>
)} )}
<div className={css.HeroAvatar} aria-hidden="true"> <div
{avatarUrl ? <img className={css.HeroAvatarImg} src={avatarUrl} alt="" /> : initial} className={aiCss.AvatarSpinHost}
role={botTyping ? 'status' : undefined}
aria-label={botTyping ? t('Room.is_typing') : undefined}
>
<div className={css.HeroAvatar} aria-hidden="true">
{avatarUrl ? <img className={css.HeroAvatarImg} src={avatarUrl} alt="" /> : initial}
</div>
{botTyping && <span className={aiCss.AvatarSpinRing} aria-hidden="true" />}
</div> </div>
<div className={css.HeroBody}> <div className={css.HeroBody}>
<div className={css.HeroTitleRow}> <div className={css.HeroTitleRow}>

View file

@ -1,4 +1,4 @@
import { keyframes, style } from '@vanilla-extract/css'; import { style } from '@vanilla-extract/css';
import { color, config, toRem } from 'folds'; import { color, config, toRem } from 'folds';
import { import {
VOJO_BUBBLE_BAND_PX, VOJO_BUBBLE_BAND_PX,
@ -342,36 +342,5 @@ export const AssistantUserBubble = style({
color: color.Surface.OnContainer, color: color.Surface.OnContainer,
}); });
// "Bot is typing" dots, shown after the last turn while the bot composes a reply. // (The assistant surface's generation indicator lives in AiChatHeader.css — an orange
const typingBlink = keyframes({ // ring around the hero avatar. The thread body deliberately renders nothing.)
'0%, 80%, 100%': { opacity: 0.2, transform: 'translateY(0)' },
'40%': { opacity: 1, transform: `translateY(-${toRem(2)})` },
});
export const TypingDots = style({
display: 'inline-flex',
alignItems: 'center',
gap: toRem(4),
padding: `${config.space.S200} 0`,
});
export const TypingDot = style({
width: toRem(6),
height: toRem(6),
borderRadius: '50%',
backgroundColor: color.Surface.OnContainer,
opacity: 0.2,
animationName: typingBlink,
animationDuration: '1.2s',
animationIterationCount: 'infinite',
selectors: {
'&:nth-child(2)': { animationDelay: '0.2s' },
'&:nth-child(3)': { animationDelay: '0.4s' },
},
'@media': {
'(prefers-reduced-motion: reduce)': {
animationName: 'none',
opacity: 0.5,
},
},
});

View file

@ -64,7 +64,6 @@ import { useMentionClickHandler } from '../../hooks/useMentionClickHandler';
import { useSpoilerClickHandler } from '../../hooks/useSpoilerClickHandler'; import { useSpoilerClickHandler } from '../../hooks/useSpoilerClickHandler';
import { useChannelsMode } from '../../hooks/useChannelsMode'; import { useChannelsMode } from '../../hooks/useChannelsMode';
import { useIsOneOnOne } from '../../hooks/useRoom'; import { useIsOneOnOne } from '../../hooks/useRoom';
import { useRoomTypingMember } from '../../hooks/useRoomTypingMembers';
import { useTheme } from '../../hooks/useTheme'; import { useTheme } from '../../hooks/useTheme';
import { useImagePackRooms } from '../../hooks/useImagePackRooms'; import { useImagePackRooms } from '../../hooks/useImagePackRooms';
import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag'; import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag';
@ -370,10 +369,8 @@ export function ThreadDrawer({
const isOneOnOne = useIsOneOnOne(); const isOneOnOne = useIsOneOnOne();
const channelsMode = useChannelsMode(); const channelsMode = useChannelsMode();
const isBridged = channelsMode && isBridgedRoom(room); const isBridged = channelsMode && isBridgedRoom(room);
// Assistant-mode typing indicator: in a 1:1 bot DM the only other member is the bot, so any // The assistant surface shows NO in-chat generation indicator: the bot's typing
// non-self typing receipt means the bot is composing a reply. Drives the typing-dots row. // receipt drives the orange spinner around the hero avatar in AiChatHeader instead.
const typingReceipts = useRoomTypingMember(room.roomId);
const botTyping = assistantStyle && typingReceipts.some((r) => r.userId !== mx.getUserId());
const [showDeveloperTools] = useSetting(settingsAtom, 'developerTools'); const [showDeveloperTools] = useSetting(settingsAtom, 'developerTools');
const roomToParents = useAtomValue(roomToParentsAtom); const roomToParents = useAtomValue(roomToParentsAtom);
@ -934,19 +931,6 @@ export function ThreadDrawer({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [repliesCount, myUserId, tryMarkThreadRead]); }, [repliesCount, myUserId, tryMarkThreadRead]);
// Assistant typing-dots follow. The "bot is typing" row renders INSIDE the scroll, after the
// last reply. When the bot starts composing while we're parked at the bottom, those dots add
// height below the fold, the bottom sentinel scrolls out, and the IntersectionObserver flips
// isAtBottomRef false — which would then make the growth effect suppress the bot's (non-own)
// reply. So snap to the new bottom when the dots appear (pre-paint, so the sentinel never
// leaves view). Only when already at-bottom — a user reading older replies is never yanked down.
useLayoutEffect(() => {
if (!botTyping) return;
const host = scrollHostRef.current;
if (!host || !isAtBottomRef.current) return;
host.scrollTop = host.scrollHeight;
}, [botTyping]);
// Stay at-bottom when the drawer scroll container resizes — Android // Stay at-bottom when the drawer scroll container resizes — Android
// soft-keyboard open/close shrinks the viewport, and Capacitor's // soft-keyboard open/close shrinks the viewport, and Capacitor's
// default `windowSoftInputMode=adjustResize` shrinks the WebView // default `windowSoftInputMode=adjustResize` shrinks the WebView
@ -1378,15 +1362,6 @@ export function ThreadDrawer({
{renderThreadEvent(reply)} {renderThreadEvent(reply)}
</MessageErrorBoundary> </MessageErrorBoundary>
))} ))}
{botTyping && (
<div className={css.AssistantBotRow}>
<div className={css.TypingDots} aria-label={t('Room.is_typing')}>
<span className={css.TypingDot} />
<span className={css.TypingDot} />
<span className={css.TypingDot} />
</div>
</div>
)}
<div ref={setBottomSentinel} /> <div ref={setBottomSentinel} />
</div> </div>
); );

View file

@ -7,6 +7,17 @@ import { settingsAtom } from './settings';
export const TYPING_TIMEOUT_MS = 5000; // 5 seconds export const TYPING_TIMEOUT_MS = 5000; // 5 seconds
// Receiver-side safety-net expiry for a typing receipt we DISPLAY. Deliberately much
// longer than TYPING_TIMEOUT_MS (the sender-side throttle/expiry above): Synapse does
// NOT re-emit m.typing while the room's typing list is unchanged, and the AI bot
// legitimately "types" for its whole generation, refreshing server-side every 20s
// against a 30s server expiry — so with a 5s local expiry the indicator died 5s in
// and never came back (no fresh PUT ever arrives). A human's stop-typing still clears
// instantly (the server emits the removal → DELETE via sync); this only guards
// against a receipt stuck by a missed event, so it just needs to outlive the server's
// own 30s expiry window, not be snappy.
export const TYPING_RECEIPT_EXPIRE_MS = 45_000;
export type TypingReceipt = { export type TypingReceipt = {
userId: string; userId: string;
ts: number; ts: number;
@ -95,7 +106,7 @@ export const roomIdToTypingMembersAtom = atom<
get(baseRoomIdToTypingMembersAtom), get(baseRoomIdToTypingMembersAtom),
roomId, roomId,
userId, userId,
TYPING_TIMEOUT_MS TYPING_RECEIPT_EXPIRE_MS
); );
if (timeout) { if (timeout) {
set( set(
@ -109,7 +120,7 @@ export const roomIdToTypingMembersAtom = atom<
) )
); );
} }
}, TYPING_TIMEOUT_MS); }, TYPING_RECEIPT_EXPIRE_MS);
} }
if ( if (