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:
parent
0a538a9e0b
commit
bbf2e55165
5 changed files with 73 additions and 65 deletions
39
src/app/features/bots/AiChatHeader.css.ts
Normal file
39
src/app/features/bots/AiChatHeader.css.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -11,11 +11,13 @@ import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
|||
import { stopPropagation } from '../../utils/keyboard';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
import { useRoomTypingMember } from '../../hooks/useRoomTypingMembers';
|
||||
import { mxcUrlToHttp } from '../../utils/matrix';
|
||||
// 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
|
||||
// below mirrors BotShellHero verbatim — keep them in sync. Only the ⋮ menu differs (AiChatMenu).
|
||||
import * as css from './BotShell.css';
|
||||
import * as aiCss from './AiChatHeader.css';
|
||||
|
||||
// Initial for the avatar block — same rule as BotShellHero.
|
||||
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
|
||||
: 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) => {
|
||||
setMenuAnchor(evt.currentTarget.getBoundingClientRect());
|
||||
};
|
||||
|
|
@ -79,9 +86,16 @@ export function AiChatHeader({ preset, room, onNewChat, onOpenHistory }: AiChatH
|
|||
)}
|
||||
</BackRouteHandler>
|
||||
)}
|
||||
<div
|
||||
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 className={css.HeroBody}>
|
||||
<div className={css.HeroTitleRow}>
|
||||
<span className={css.HeroName}>{preset.name}</span>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { keyframes, style } from '@vanilla-extract/css';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
import { color, config, toRem } from 'folds';
|
||||
import {
|
||||
VOJO_BUBBLE_BAND_PX,
|
||||
|
|
@ -342,36 +342,5 @@ export const AssistantUserBubble = style({
|
|||
color: color.Surface.OnContainer,
|
||||
});
|
||||
|
||||
// "Bot is typing" dots, shown after the last turn while the bot composes a reply.
|
||||
const typingBlink = keyframes({
|
||||
'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,
|
||||
},
|
||||
},
|
||||
});
|
||||
// (The assistant surface's generation indicator lives in AiChatHeader.css — an orange
|
||||
// ring around the hero avatar. The thread body deliberately renders nothing.)
|
||||
|
|
|
|||
|
|
@ -64,7 +64,6 @@ import { useMentionClickHandler } from '../../hooks/useMentionClickHandler';
|
|||
import { useSpoilerClickHandler } from '../../hooks/useSpoilerClickHandler';
|
||||
import { useChannelsMode } from '../../hooks/useChannelsMode';
|
||||
import { useIsOneOnOne } from '../../hooks/useRoom';
|
||||
import { useRoomTypingMember } from '../../hooks/useRoomTypingMembers';
|
||||
import { useTheme } from '../../hooks/useTheme';
|
||||
import { useImagePackRooms } from '../../hooks/useImagePackRooms';
|
||||
import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag';
|
||||
|
|
@ -370,10 +369,8 @@ export function ThreadDrawer({
|
|||
const isOneOnOne = useIsOneOnOne();
|
||||
const channelsMode = useChannelsMode();
|
||||
const isBridged = channelsMode && isBridgedRoom(room);
|
||||
// Assistant-mode typing indicator: in a 1:1 bot DM the only other member is the bot, so any
|
||||
// non-self typing receipt means the bot is composing a reply. Drives the typing-dots row.
|
||||
const typingReceipts = useRoomTypingMember(room.roomId);
|
||||
const botTyping = assistantStyle && typingReceipts.some((r) => r.userId !== mx.getUserId());
|
||||
// The assistant surface shows NO in-chat generation indicator: the bot's typing
|
||||
// receipt drives the orange spinner around the hero avatar in AiChatHeader instead.
|
||||
const [showDeveloperTools] = useSetting(settingsAtom, 'developerTools');
|
||||
|
||||
const roomToParents = useAtomValue(roomToParentsAtom);
|
||||
|
|
@ -934,19 +931,6 @@ export function ThreadDrawer({
|
|||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [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
|
||||
// soft-keyboard open/close shrinks the viewport, and Capacitor's
|
||||
// default `windowSoftInputMode=adjustResize` shrinks the WebView
|
||||
|
|
@ -1378,15 +1362,6 @@ export function ThreadDrawer({
|
|||
{renderThreadEvent(reply)}
|
||||
</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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -7,6 +7,17 @@ import { settingsAtom } from './settings';
|
|||
|
||||
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 = {
|
||||
userId: string;
|
||||
ts: number;
|
||||
|
|
@ -95,7 +106,7 @@ export const roomIdToTypingMembersAtom = atom<
|
|||
get(baseRoomIdToTypingMembersAtom),
|
||||
roomId,
|
||||
userId,
|
||||
TYPING_TIMEOUT_MS
|
||||
TYPING_RECEIPT_EXPIRE_MS
|
||||
);
|
||||
if (timeout) {
|
||||
set(
|
||||
|
|
@ -109,7 +120,7 @@ export const roomIdToTypingMembersAtom = atom<
|
|||
)
|
||||
);
|
||||
}
|
||||
}, TYPING_TIMEOUT_MS);
|
||||
}, TYPING_RECEIPT_EXPIRE_MS);
|
||||
}
|
||||
|
||||
if (
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue