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 { 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}>
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue