From bbf2e55165ae28d9e3f4005259d23c4597d2c45a Mon Sep 17 00:00:00 2001 From: heaven Date: Fri, 3 Jul 2026 00:04:34 +0300 Subject: [PATCH] =?UTF-8?q?fix(bots):=20AI=20typing=20indicator=20?= =?UTF-8?q?=E2=80=94=2045s=20receipt=20expiry=20(5s=20died=20mid-generatio?= =?UTF-8?q?n,=20Synapse=20doesn't=20re-emit=20unchanged=20m.typing);=20ava?= =?UTF-8?q?tar=20ring=20spinner=20in=20header=20replaces=20dots?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/features/bots/AiChatHeader.css.ts | 39 +++++++++++++++++++++++ src/app/features/bots/AiChatHeader.tsx | 18 +++++++++-- src/app/features/room/ThreadDrawer.css.ts | 37 ++------------------- src/app/features/room/ThreadDrawer.tsx | 29 ++--------------- src/app/state/typingMembers.ts | 15 +++++++-- 5 files changed, 73 insertions(+), 65 deletions(-) create mode 100644 src/app/features/bots/AiChatHeader.css.ts 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 )} )} -