feat(ai-bot): rebuild the @ai overflow menu to match 1-1 chats, restyle the chats-list panel, and slim the notification button

This commit is contained in:
heaven 2026-06-07 03:29:59 +03:00
parent ae387c735d
commit f41ea049cc
5 changed files with 95 additions and 147 deletions

View file

@ -56,8 +56,17 @@ export function RoomNotificationModeSwitcher({
const [menuCords, setMenuCords] = useState<RectCords>(); const [menuCords, setMenuCords] = useState<RectCords>();
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => { const open = !!menuCords;
const handleToggleMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
// Second click on the trigger CLOSES the popout instead of re-anchoring (reopening) it. `open`
// is the pre-click render value: even though focus-trap's `clickOutsideDeactivates` also fires
// `onDeactivate` for this same click, both paths resolve to "close" — so there's no reopen race
// regardless of listener order.
if (open) {
setMenuCords(undefined);
} else {
setMenuCords(evt.currentTarget.getBoundingClientRect()); setMenuCords(evt.currentTarget.getBoundingClientRect());
}
}; };
const handleClose = () => { const handleClose = () => {
@ -117,7 +126,7 @@ export function RoomNotificationModeSwitcher({
</FocusTrap> </FocusTrap>
} }
> >
{children(handleOpenMenu, !!menuCords, changing)} {children(handleToggleMenu, open, changing)}
</PopOut> </PopOut>
); );
} }

View file

@ -1,21 +1,17 @@
import React, { forwardRef } from 'react'; import React, { forwardRef, useState } from 'react';
import { Box, Icon, Icons, Line, Menu, MenuItem, Spinner, Text, config, toRem } from 'folds'; import { Icons, Menu, Spinner } from 'folds';
import type { Room } from 'matrix-js-sdk'; import type { Room } from 'matrix-js-sdk';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
import { useRoomUnread } from '../../state/hooks/unread';
import { roomToUnreadAtom } from '../../state/room/roomToUnread';
import { import {
getRoomNotificationMode, getRoomNotificationMode,
getRoomNotificationModeIcon,
useRoomsNotificationPreferencesContext, useRoomsNotificationPreferencesContext,
} from '../../hooks/useRoomsNotificationPreferences'; } from '../../hooks/useRoomsNotificationPreferences';
import { RoomNotificationModeSwitcher } from '../../components/RoomNotificationSwitcher'; import { RoomNotificationModeSwitcher } from '../../components/RoomNotificationSwitcher';
import { LeaveRoomPrompt } from '../../components/leave-room-prompt'; import { LeaveRoomPrompt } from '../../components/leave-room-prompt';
import { UseStateProvider } from '../../components/UseStateProvider'; // Reuse the EXACT row vocabulary + popout chrome of the 1:1 chat's ⋮ menu (RoomActionsMenu) so the
import { markAsRead } from '../../utils/notifications'; // AI surface's overflow menu is the same popup, styled identically — only the action set differs.
import { ActionRow, ActionSectionLine, RowChevron } from '../room/room-actions/RoomActions';
import * as css from '../room/room-actions/RoomActions.css';
type AiChatMenuProps = { type AiChatMenuProps = {
room: Room; room: Room;
@ -25,119 +21,20 @@ type AiChatMenuProps = {
onOpenPrivacy: () => void; onOpenPrivacy: () => void;
}; };
// The native AI conversation surface's ⋮ menu. Deliberately NOT BotShellMenu: there is no widget // The native AI conversation surface's ⋮ menu. Same popup as the 1:1 chat overflow menu
// here, so there is no "Show chat" toggle (it would be meaningless). It carries the conversation // (RoomActionsMenu: `Menu variant="Surface"` + ActionRow rows + ActionSectionLine), carrying the
// actions (New chat / History / Privacy & data) plus the generic room actions (mark read / // AI conversation actions (New chat / Chats / Privacy & data) plus the generic room actions
// notifications / leave) // (notifications / leave). No "Mark as read" — useless here — and no widget "Show chat".
// that apply to any DM — reusing the same shared switcher / leave-prompt primitives so behaviour
// can't drift from the rest of the app.
export const AiChatMenu = forwardRef<HTMLDivElement, AiChatMenuProps>( export const AiChatMenu = forwardRef<HTMLDivElement, AiChatMenuProps>(
({ room, requestClose, onNewChat, onOpenHistory, onOpenPrivacy }, ref) => { ({ room, requestClose, onNewChat, onOpenHistory, onOpenPrivacy }, ref) => {
const { t } = useTranslation(); const { t } = useTranslation();
const mx = useMatrixClient();
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
const notificationPreferences = useRoomsNotificationPreferencesContext(); const notificationPreferences = useRoomsNotificationPreferencesContext();
const notificationMode = getRoomNotificationMode(notificationPreferences, room.roomId); const notificationMode = getRoomNotificationMode(notificationPreferences, room.roomId);
const handleMarkAsRead = () => { const [promptLeave, setPromptLeave] = useState(false);
markAsRead(mx, room.roomId, hideActivity);
requestClose();
};
return ( return (
<Menu ref={ref} style={{ maxWidth: toRem(264), width: '100vw' }}> <Menu ref={ref} variant="Surface" className={css.PopoutMenu}>
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
<MenuItem
onClick={() => {
onNewChat();
requestClose();
}}
size="300"
after={<Icon size="100" src={Icons.Plus} />}
radii="300"
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
{t('Bots.conversations.new_chat')}
</Text>
</MenuItem>
<MenuItem
onClick={() => {
onOpenHistory();
requestClose();
}}
size="300"
after={<Icon size="100" src={Icons.RecentClock} />}
radii="300"
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
{t('Bots.conversations.title')}
</Text>
</MenuItem>
<MenuItem
onClick={handleMarkAsRead}
size="300"
after={<Icon size="100" src={Icons.CheckTwice} />}
radii="300"
disabled={!unread}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
{t('Room.mark_as_read')}
</Text>
</MenuItem>
<RoomNotificationModeSwitcher roomId={room.roomId} value={notificationMode}>
{(handleOpen, opened, changing) => (
<MenuItem
size="300"
after={
changing ? (
<Spinner size="100" variant="Secondary" />
) : (
<Icon size="100" src={getRoomNotificationModeIcon(notificationMode)} />
)
}
radii="300"
aria-pressed={opened}
onClick={handleOpen}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
{t('Room.notifications')}
</Text>
</MenuItem>
)}
</RoomNotificationModeSwitcher>
<MenuItem
onClick={() => {
onOpenPrivacy();
requestClose();
}}
size="300"
after={<Icon size="100" src={Icons.ShieldLock} />}
radii="300"
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
{t('Bots.privacy.menu')}
</Text>
</MenuItem>
</Box>
<Line variant="Surface" size="300" />
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
<UseStateProvider initial={false}>
{(promptLeave, setPromptLeave) => (
<>
<MenuItem
onClick={() => setPromptLeave(true)}
variant="Critical"
fill="None"
size="300"
after={<Icon size="100" src={Icons.ArrowGoLeft} />}
radii="300"
aria-pressed={promptLeave}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
{t('Room.leave_room')}
</Text>
</MenuItem>
{promptLeave && ( {promptLeave && (
<LeaveRoomPrompt <LeaveRoomPrompt
roomId={room.roomId} roomId={room.roomId}
@ -145,10 +42,60 @@ export const AiChatMenu = forwardRef<HTMLDivElement, AiChatMenuProps>(
onCancel={() => setPromptLeave(false)} onCancel={() => setPromptLeave(false)}
/> />
)} )}
</>
<div className={css.PopoutGroup}>
<ActionRow
icon={Icons.Plus}
label={t('Bots.conversations.new_chat')}
onClick={() => {
onNewChat();
requestClose();
}}
/>
<ActionRow
icon={Icons.RecentClock}
label={t('Bots.conversations.title')}
onClick={() => {
onOpenHistory();
requestClose();
}}
ariaHasPopup
trailing={<RowChevron />}
/>
<RoomNotificationModeSwitcher roomId={room.roomId} value={notificationMode}>
{(handleOpen, opened, changing) => (
<ActionRow
icon={Icons.Bell}
label={t('Room.notifications')}
onClick={handleOpen}
ariaPressed={opened}
ariaHasPopup
trailing={changing ? <Spinner size="100" variant="Secondary" /> : <RowChevron />}
/>
)} )}
</UseStateProvider> </RoomNotificationModeSwitcher>
</Box> <ActionRow
icon={Icons.ShieldLock}
label={t('Bots.privacy.menu')}
onClick={() => {
onOpenPrivacy();
requestClose();
}}
ariaHasPopup
trailing={<RowChevron />}
/>
</div>
<ActionSectionLine />
<div className={css.PopoutGroup}>
<ActionRow
icon={Icons.ArrowGoLeft}
label={t('Room.leave_room')}
onClick={() => setPromptLeave(true)}
accent="critical"
ariaPressed={promptLeave}
/>
</div>
</Menu> </Menu>
); );
} }

View file

@ -1,6 +1,7 @@
import { style } from '@vanilla-extract/css'; import { globalStyle, style } from '@vanilla-extract/css';
import { color, config, toRem } from 'folds'; import { color, config, toRem } from 'folds';
import { VOJO_HORSESHOE_GAP_PX } from '../../styles/horseshoe'; import { VOJO_HORSESHOE_GAP_PX, VOJO_HORSESHOE_RADIUS_PX } from '../../styles/horseshoe';
import { ChatComposer } from '../room/RoomView.css';
// Bridge widgets centre their body content in a 960px column (apps/widget-telegram/src/styles.css // Bridge widgets centre their body content in a 960px column (apps/widget-telegram/src/styles.css
// `.app { max-width: 960px; margin: 0 auto }`, mirrored by the host hero's `BotShell.HeroInner`). // `.app { max-width: 960px; margin: 0 auto }`, mirrored by the host hero's `BotShell.HeroInner`).
@ -53,6 +54,13 @@ export const Main = style({
flexDirection: 'column', flexDirection: 'column',
}); });
// Hide the composer/input form entirely while the chats list (History panel) is open; the chat
// messages stay visible. `ChatComposer` marks both the new-chat composer and the in-thread
// composer, so both are removed.
globalStyle(`${Main}[data-history-open] ${ChatComposer}`, {
display: 'none',
});
// Chat-history panel, anchored to the RIGHT edge of the body, slid off-screen until toggled. Sits // Chat-history panel, anchored to the RIGHT edge of the body, slid off-screen until toggled. Sits
// above the Main content (zIndex 2) over a click-to-close Backdrop (zIndex 1). Raised // above the Main content (zIndex 2) over a click-to-close Backdrop (zIndex 1). Raised
// Surface.Container tone + a soft cast shadow lift it off the surface so the open/close reads as a // Surface.Container tone + a soft cast shadow lift it off the surface so the open/close reads as a
@ -62,28 +70,30 @@ export const History = style({
insetBlock: 0, insetBlock: 0,
right: 0, right: 0,
zIndex: 2, zIndex: 2,
width: '50%', // ~1.1× narrower than before (440→400 / 300→280 / 50%→45%).
maxWidth: toRem(440), width: '45%',
minWidth: toRem(300), maxWidth: toRem(400),
minWidth: toRem(280),
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
// Composer/input-form tone (Surface.Container = #0d0e11 — the same fill the message input uses).
backgroundColor: color.Surface.Container, backgroundColor: color.Surface.Container,
borderLeft: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`, // No border — the panel separates from the chat via its own cast shadow + the rounded corner.
// Round the inner (top-left) corner with the app-wide horseshoe radius, matching the Direct-tab
// chat list (PageNav content) and the composer card.
borderTopLeftRadius: toRem(VOJO_HORSESHOE_RADIUS_PX),
transform: 'translateX(100%)', transform: 'translateX(100%)',
transition: 'transform 220ms cubic-bezier(0.22, 1, 0.36, 1)', transition: 'transform 220ms cubic-bezier(0.22, 1, 0.36, 1)',
selectors: { selectors: {
// Shadow ONLY when open. The base `translateX(100%)` parks the panel off the right edge, but its // No cast shadow — the panel separates from the chat via its darker bg + rounded corner only.
// cast shadow (offset -12px, i.e. leftward, + 40px blur) reaches back INTO the body and Body's
// `overflow: hidden` can't clip it (the leak lands inside the box, not past its edge) — so a base
// shadow paints a dark sliver on the right edge even while the drawer is closed.
'&[data-open]': { '&[data-open]': {
transform: 'translateX(0)', transform: 'translateX(0)',
boxShadow: '-12px 0 40px rgba(0, 0, 0, 0.32)',
}, },
}, },
'@media': { '@media': {
'(prefers-reduced-motion: reduce)': { transition: 'none' }, '(prefers-reduced-motion: reduce)': { transition: 'none' },
'(max-width: 600px)': { width: '86%', maxWidth: 'none' }, // Narrower on native so there's a bigger gap on the left of the chats list.
'(max-width: 600px)': { width: '78%', maxWidth: 'none' },
}, },
}); });
@ -150,9 +160,8 @@ export const Backdrop = style({
margin: 0, margin: 0,
appearance: 'none', appearance: 'none',
cursor: 'default', cursor: 'default',
// Gentler scrim than the stock 0.4 — the panel's own shadow already separates it from the // No scrim — the chat behind must not dim when the chats list opens (click-to-close only).
// surface, so the chat behind only needs a light dim, not a heavy black-out. backgroundColor: 'transparent',
backgroundColor: 'rgba(0, 0, 0, 0.28)',
}); });
// History row: a rounded card holding the conversation title and a muted last-activity time. // History row: a rounded card holding the conversation title and a muted last-activity time.

View file

@ -193,7 +193,7 @@ export function BotConversations({ preset, room, rootId }: BotConversationsProps
</div> </div>
</aside> </aside>
<div className={css.Main}> <div className={css.Main} data-history-open={historyOpen || undefined}>
{rootId ? ( {rootId ? (
<ThreadDrawer <ThreadDrawer
key={`${room.roomId}/${rootId}`} key={`${room.roomId}/${rootId}`}

View file

@ -29,14 +29,7 @@ import { getViaServers } from '../../../plugins/via-servers';
import { copyToClipboard } from '../../../utils/dom'; import { copyToClipboard } from '../../../utils/dom';
import { markAsRead } from '../../../utils/notifications'; import { markAsRead } from '../../../utils/notifications';
import { JumpToTime } from '../jump-to-time'; import { JumpToTime } from '../jump-to-time';
import { import { ActionRow, ActionSectionLine, BotWidgetActionRow, RowChevron } from './RoomActions';
ActionRow,
ActionSectionLine,
BotWidgetActionRow,
RowChevron,
RowTrailingText,
useNotificationModeLabel,
} from './RoomActions';
import * as css from './RoomActions.css'; import * as css from './RoomActions.css';
type RoomActionsMenuProps = { type RoomActionsMenuProps = {
@ -65,7 +58,6 @@ export const RoomActionsMenu = forwardRef<HTMLDivElement, RoomActionsMenuProps>(
const canInvite = permissions.action('invite', mx.getSafeUserId()); const canInvite = permissions.action('invite', mx.getSafeUserId());
const notificationPreferences = useRoomsNotificationPreferencesContext(); const notificationPreferences = useRoomsNotificationPreferencesContext();
const notificationMode = getRoomNotificationMode(notificationPreferences, room.roomId); const notificationMode = getRoomNotificationMode(notificationPreferences, room.roomId);
const notificationModeLabel = useNotificationModeLabel(notificationMode);
const pinnedEvents = useRoomPinnedEvents(room); const pinnedEvents = useRoomPinnedEvents(room);
const { navigateRoom } = useRoomNavigate(); const { navigateRoom } = useRoomNavigate();
const openSettings = useOpenRoomSettings(); const openSettings = useOpenRoomSettings();
@ -148,16 +140,7 @@ export const RoomActionsMenu = forwardRef<HTMLDivElement, RoomActionsMenuProps>(
onClick={handleOpen} onClick={handleOpen}
ariaPressed={opened} ariaPressed={opened}
ariaHasPopup ariaHasPopup
trailing={ trailing={changing ? <Spinner size="100" variant="Secondary" /> : <RowChevron />}
changing ? (
<Spinner size="100" variant="Secondary" />
) : (
<>
<RowTrailingText>{notificationModeLabel}</RowTrailingText>
<RowChevron />
</>
)
}
/> />
)} )}
</RoomNotificationModeSwitcher> </RoomNotificationModeSwitcher>