diff --git a/src/app/features/bots/AiChatHeader.css.ts b/src/app/features/bots/AiChatHeader.css.ts new file mode 100644 index 00000000..5aaf678e --- /dev/null +++ b/src/app/features/bots/AiChatHeader.css.ts @@ -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, + }, + }, +}); diff --git a/src/app/features/bots/AiChatHeader.tsx b/src/app/features/bots/AiChatHeader.tsx index 6ca0930c..73c00114 100644 --- a/src/app/features/bots/AiChatHeader.tsx +++ b/src/app/features/bots/AiChatHeader.tsx @@ -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 = (evt) => { setMenuAnchor(evt.currentTarget.getBoundingClientRect()); }; @@ -79,8 +86,15 @@ export function AiChatHeader({ preset, room, onNewChat, onOpenHistory }: AiChatH )} )} -