feat(ai-chat): add a minimal privacy notice and redesign the history panel, centering the transcript and composer on web

This commit is contained in:
heaven 2026-06-02 13:31:13 +03:00
parent 58665921a4
commit 77959167fa
10 changed files with 550 additions and 45 deletions

View file

@ -953,6 +953,21 @@
"composer_placeholder": "Message Vojo AI…", "composer_placeholder": "Message Vojo AI…",
"send": "Send" "send": "Send"
}, },
"privacy": {
"menu": "Privacy & data",
"title": "Privacy, in plain words",
"subtitle": "How your messages are used.",
"intro": "Vojo AI writes answers for you. They're AI-generated and can be confidently wrong — treat them as a smart first draft and double-check anything that matters.",
"models_title": "Which AI answers you",
"models_body": "To write replies, the messages you send are processed by AI models from two providers — Grok by xAI (USA) and Google's Gemini. They handle your text under their own privacy policies.",
"avoid_title": "Please don't send secrets",
"avoid_body": "Skip passwords, card numbers, and other sensitive personal details.",
"consent": "By chatting with Vojo AI you agree that your messages are sent to these providers to generate replies.",
"learn_more": "Read the providers' own policies:",
"xai_link": "Grok (xAI)",
"gemini_link": "Gemini (Google)",
"close": "Close privacy notice"
},
"description": { "description": {
"telegram": "Connect Telegram to Vojo: private chats and groups appear in the chat list, and replies from the Vojo app are sent as normal Telegram messages.", "telegram": "Connect Telegram to Vojo: private chats and groups appear in the chat list, and replies from the Vojo app are sent as normal Telegram messages.",
"discord": "Connect Discord to Vojo: DMs and servers appear in the chat list, and replies from the Vojo app are sent as normal Discord messages. Sign-in uses a QR code from the Discord mobile app.", "discord": "Connect Discord to Vojo: DMs and servers appear in the chat list, and replies from the Vojo app are sent as normal Discord messages. Sign-in uses a QR code from the Discord mobile app.",

View file

@ -971,6 +971,21 @@
"composer_placeholder": "Сообщение для Vojo AI…", "composer_placeholder": "Сообщение для Vojo AI…",
"send": "Отправить" "send": "Отправить"
}, },
"privacy": {
"menu": "Конфиденциальность",
"title": "Конфиденциальность простыми словами",
"subtitle": "Как используются ваши сообщения.",
"intro": "Vojo AI пишет ответы за вас. Ответы генерирует ИИ, и он может уверенно ошибаться — считайте их умным черновиком и перепроверяйте всё важное.",
"models_title": "Какой ИИ вам отвечает",
"models_body": "Чтобы составить ответы, отправленные вами сообщения обрабатывают ИИ-модели двух сервисов — Grok от xAI (США) и Google Gemini. Они обрабатывают ваш текст по своим политикам конфиденциальности.",
"avoid_title": "Не отправляйте секреты",
"avoid_body": "Не отправляйте пароли, номера карт и другие чувствительные личные данные.",
"consent": "Общаясь с Vojo AI, вы соглашаетесь, что ваши сообщения отправляются этим сервисам для генерации ответов.",
"learn_more": "Почитать политики самих сервисов:",
"xai_link": "Grok (xAI)",
"gemini_link": "Gemini (Google)",
"close": "Закрыть уведомление о конфиденциальности"
},
"description": { "description": {
"telegram": "Подключите Telegram к Vojo: личные чаты и группы появятся в списке чатов, а ответы из приложения Vojo будут отправляться в Telegram как обычные сообщения.", "telegram": "Подключите Telegram к Vojo: личные чаты и группы появятся в списке чатов, а ответы из приложения Vojo будут отправляться в Telegram как обычные сообщения.",
"discord": "Подключите Discord к Vojo: личные чаты и серверы появятся в списке чатов, а ответы из приложения Vojo будут отправляться в Discord как обычные сообщения. Вход — через QR-код из мобильного Discord.", "discord": "Подключите Discord к Vojo: личные чаты и серверы появятся в списке чатов, а ответы из приложения Vojo будут отправляться в Discord как обычные сообщения. Вход — через QR-код из мобильного Discord.",

View file

@ -5,6 +5,7 @@ import FocusTrap from 'focus-trap-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import type { BotPreset } from './catalog'; import type { BotPreset } from './catalog';
import { AiChatMenu } from './AiChatMenu'; import { AiChatMenu } from './AiChatMenu';
import { AiChatPrivacy } from './AiChatPrivacy';
import { BackRouteHandler } from '../../components/BackRouteHandler'; import { BackRouteHandler } from '../../components/BackRouteHandler';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { stopPropagation } from '../../utils/keyboard'; import { stopPropagation } from '../../utils/keyboard';
@ -33,7 +34,8 @@ type AiChatHeaderProps = {
// Dedicated header for the native AI conversation surface. Renders the SAME hero as BotShellHero // Dedicated header for the native AI conversation surface. Renders the SAME hero as BotShellHero
// (shared BotShell.css), but is fully decoupled from the widget pipeline: its ⋮ opens AiChatMenu // (shared BotShell.css), but is fully decoupled from the widget pipeline: its ⋮ opens AiChatMenu
// (New chat / History / generic room actions) — no "Show chat", no widget toggle, no BotShellMenu. // (New chat / History / Privacy & data / generic room actions) — no "Show chat", no widget toggle,
// no BotShellMenu. The privacy notice modal (AiChatPrivacy) is owned here too.
export function AiChatHeader({ preset, room, onNewChat, onOpenHistory }: AiChatHeaderProps) { export function AiChatHeader({ preset, room, onNewChat, onOpenHistory }: AiChatHeaderProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const mx = useMatrixClient(); const mx = useMatrixClient();
@ -41,6 +43,7 @@ export function AiChatHeader({ preset, room, onNewChat, onOpenHistory }: AiChatH
const screenSize = useScreenSizeContext(); const screenSize = useScreenSizeContext();
const isMobile = screenSize === ScreenSize.Mobile; const isMobile = screenSize === ScreenSize.Mobile;
const [menuAnchor, setMenuAnchor] = useState<RectCords>(); const [menuAnchor, setMenuAnchor] = useState<RectCords>();
const [privacyOpen, setPrivacyOpen] = useState(false);
const avatarMxc = room.getMember(preset.mxid)?.getMxcAvatarUrl(); const avatarMxc = room.getMember(preset.mxid)?.getMxcAvatarUrl();
const avatarUrl = avatarMxc const avatarUrl = avatarMxc
@ -133,10 +136,13 @@ export function AiChatHeader({ preset, room, onNewChat, onOpenHistory }: AiChatH
requestClose={() => setMenuAnchor(undefined)} requestClose={() => setMenuAnchor(undefined)}
onNewChat={onNewChat} onNewChat={onNewChat}
onOpenHistory={onOpenHistory} onOpenHistory={onOpenHistory}
onOpenPrivacy={() => setPrivacyOpen(true)}
/> />
</FocusTrap> </FocusTrap>
} }
/> />
{privacyOpen && <AiChatPrivacy onClose={() => setPrivacyOpen(false)} />}
</header> </header>
); );
} }

View file

@ -22,15 +22,17 @@ type AiChatMenuProps = {
requestClose: () => void; requestClose: () => void;
onNewChat: () => void; onNewChat: () => void;
onOpenHistory: () => void; onOpenHistory: () => void;
onOpenPrivacy: () => void;
}; };
// The native AI conversation surface's ⋮ menu. Deliberately NOT BotShellMenu: there is no widget // The native AI conversation surface's ⋮ menu. Deliberately NOT BotShellMenu: there is no widget
// here, so there is no "Show chat" toggle (it would be meaningless). It carries the conversation // here, so there is no "Show chat" toggle (it would be meaningless). It carries the conversation
// actions (New chat / History) plus the generic room actions (mark read / notifications / leave) // actions (New chat / History / Privacy & data) plus the generic room actions (mark read /
// notifications / leave)
// that apply to any DM — reusing the same shared switcher / leave-prompt primitives so behaviour // that apply to any DM — reusing the same shared switcher / leave-prompt primitives so behaviour
// can't drift from the rest of the app. // 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 }, ref) => { ({ room, requestClose, onNewChat, onOpenHistory, onOpenPrivacy }, ref) => {
const { t } = useTranslation(); const { t } = useTranslation();
const mx = useMatrixClient(); const mx = useMatrixClient();
const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
@ -44,7 +46,7 @@ export const AiChatMenu = forwardRef<HTMLDivElement, AiChatMenuProps>(
}; };
return ( return (
<Menu ref={ref} style={{ maxWidth: toRem(240), width: '100vw' }}> <Menu ref={ref} style={{ maxWidth: toRem(264), width: '100vw' }}>
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}> <Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
<MenuItem <MenuItem
onClick={() => { onClick={() => {
@ -104,6 +106,19 @@ export const AiChatMenu = forwardRef<HTMLDivElement, AiChatMenuProps>(
</MenuItem> </MenuItem>
)} )}
</RoomNotificationModeSwitcher> </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> </Box>
<Line variant="Surface" size="300" /> <Line variant="Surface" size="300" />
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}> <Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>

View file

@ -0,0 +1,156 @@
import { style } from '@vanilla-extract/css';
import { color, config, toRem } from 'folds';
// Centered privacy dialog — a fully self-styled card (NOT a folds modal recipe, whose fixed-rem
// sizing didn't give these caps and clipped the body in an earlier attempt). Owns its own surface,
// radius, border and shadow so the body fills the full width on every platform. Width caps at 520px
// (the modal width the removed widget's About panel used), shrinking to 90vw on phones.
export const DialogCard = style({
width: '90vw',
maxWidth: toRem(520),
maxHeight: '85vh',
display: 'flex',
flexDirection: 'column',
minHeight: 0,
overflow: 'hidden',
backgroundColor: color.Surface.Container,
borderRadius: config.radii.R400,
border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
boxShadow: '0 20px 60px rgba(0, 0, 0, 0.5)',
});
// Branded header: a fleet shield badge + title + one-line subtitle, then the close button. No
// bottom-border bar (that's what made it read as a generic cinny dialog) — the body's own spacing
// separates it.
export const Header = style({
flexShrink: 0,
display: 'flex',
alignItems: 'flex-start',
gap: config.space.S300,
padding: `${config.space.S400} ${config.space.S400} ${config.space.S300}`,
});
// Rounded-square fleet badge, same accent vocabulary as the bot avatars (BotShell.HeroAvatar).
export const HeaderBadge = style({
flexShrink: 0,
width: toRem(40),
height: toRem(40),
borderRadius: config.radii.R400,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: color.Primary.Container,
color: color.Primary.Main,
});
export const HeaderText = style({
flexGrow: 1,
minWidth: 0,
display: 'flex',
flexDirection: 'column',
gap: toRem(2),
paddingTop: toRem(2),
});
export const HeaderTitle = style({
fontSize: toRem(18),
fontWeight: 700,
lineHeight: toRem(22),
color: color.Surface.OnContainer,
});
export const HeaderSubtitle = style({
fontSize: toRem(13),
lineHeight: toRem(17),
color: color.SurfaceVariant.OnContainer,
opacity: 0.7,
});
// Folds `Scroll` owns the overflow + the app's thin, hover-revealed scrollbar styling (so it
// matches every other scroll surface). Here we only give it the flex sizing to fill the card's
// remaining height between the header and the bottom; minHeight:0 lets it shrink so it scrolls
// instead of pushing the card past its maxHeight.
export const BodyScroll = style({
flexGrow: 1,
minHeight: 0,
});
export const Body = style({
display: 'flex',
flexDirection: 'column',
gap: config.space.S500,
padding: `0 ${config.space.S400} ${config.space.S400}`,
});
export const SectionTitle = style({
fontSize: toRem(14),
fontWeight: 600,
color: color.Surface.OnContainer,
});
export const SectionBody = style({
fontSize: toRem(14),
lineHeight: toRem(21),
color: color.SurfaceVariant.OnContainer,
});
// "Using it = OK with this" — the consent line gets a soft fleet-tinted card with a fleet rule so
// it reads as the one binding sentence without shouting. Matches the DAWN raised-surface vocabulary.
export const Consent = style({
margin: 0,
padding: config.space.S300,
borderRadius: config.radii.R400,
backgroundColor: color.Surface.ContainerActive,
borderLeft: `${toRem(3)} solid ${color.Primary.Main}`,
fontSize: toRem(13),
lineHeight: toRem(19),
color: color.Surface.OnContainer,
});
export const Links = style({
display: 'flex',
flexDirection: 'column',
gap: config.space.S200,
});
export const LearnMore = style({
fontSize: toRem(13),
color: color.SurfaceVariant.OnContainer,
opacity: 0.7,
});
// Clean provider link: a raised chip showing the provider name + the full URL verbatim, so the
// destination is explicit. fleet-tinted on hover/focus.
export const PolicyLink = style({
display: 'flex',
flexDirection: 'column',
gap: toRem(1),
padding: `${config.space.S200} ${config.space.S300}`,
borderRadius: config.radii.R400,
backgroundColor: color.SurfaceVariant.Container,
textDecoration: 'none',
selectors: {
'&:hover, &:focus-visible': {
backgroundColor: color.SurfaceVariant.ContainerHover,
outline: 'none',
},
},
});
export const LinkProvider = style({
fontSize: toRem(13),
fontWeight: 600,
color: color.Surface.OnContainer,
});
export const LinkUrl = style({
fontSize: toRem(12),
fontFamily: 'ui-monospace, "JetBrains Mono", "SF Mono", monospace',
color: color.Primary.Main,
wordBreak: 'break-all',
selectors: {
[`${PolicyLink}:hover &, ${PolicyLink}:focus-visible &`]: {
textDecoration: 'underline',
},
},
});

View file

@ -0,0 +1,138 @@
import React from 'react';
import FocusTrap from 'focus-trap-react';
import {
Box,
Icon,
IconButton,
Icons,
Overlay,
OverlayBackdrop,
OverlayCenter,
Scroll,
} from 'folds';
import { useTranslation } from 'react-i18next';
import { stopPropagation } from '../../utils/keyboard';
import * as css from './AiChatPrivacy.css';
// xAI / Google first-party policy links. Shown as plain, full URLs (no vague "learn more" wording)
// so it's obvious where the user is going and the exact retention/usage terms stay at the source.
const XAI_PRIVACY_URL = 'https://x.ai/legal/privacy-policy';
const GEMINI_TERMS_URL = 'https://ai.google.dev/gemini-api/terms';
type AiChatPrivacyProps = {
onClose: () => void;
};
type SectionProps = {
title: string;
body: string;
};
function Section({ title, body }: SectionProps) {
return (
<Box direction="Column" gap="100">
<span className={css.SectionTitle}>{title}</span>
<span className={css.SectionBody}>{body}</span>
</Box>
);
}
type PolicyLinkProps = {
provider: string;
url: string;
};
// A clean, explicit provider link: the provider name on top, the full URL underneath as the
// clickable target (shown verbatim, https included, so there's no hidden destination).
function PolicyLink({ provider, url }: PolicyLinkProps) {
return (
<a className={css.PolicyLink} href={url} target="_blank" rel="noreferrer noopener">
<span className={css.LinkProvider}>{provider}</span>
<span className={css.LinkUrl}>{url}</span>
</a>
);
}
// The native AI chat's privacy notice — successor to the removed widget's About modal. Deliberately
// MINIMAL (the bar for a Google Play AI-data disclosure, no implementation detail): it's an AI
// assistant, replies can be wrong, the messages you send are processed by two providers (xAI's Grok
// and Google's Gemini), don't send secrets, plus links to the providers' own policies.
//
// Built as a fully self-styled card (like AuthLayout's ServerDialogCard), NOT a folds modal recipe.
// folds `Modal` (the flex-column + Scroll pattern in CreateRoomModal) is the usual maxHeight+scroll
// card, but its size tokens are fixed rems and don't give the responsive 90vw / 520px / 85vh caps
// this notice wants; an earlier folds `Dialog` attempt also clipped the body. Owning the card chrome
// here keeps the body filling the full width on every platform, with folds `Scroll` for the list.
export function AiChatPrivacy({ onClose }: AiChatPrivacyProps) {
const { t } = useTranslation();
return (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: onClose,
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<div
className={css.DialogCard}
role="dialog"
aria-modal="true"
aria-label={t('Bots.privacy.title')}
>
<header className={css.Header}>
<span className={css.HeaderBadge} aria-hidden="true">
<Icon src={Icons.ShieldLock} />
</span>
<div className={css.HeaderText}>
<span className={css.HeaderTitle}>{t('Bots.privacy.title')}</span>
<span className={css.HeaderSubtitle}>{t('Bots.privacy.subtitle')}</span>
</div>
<IconButton
size="300"
variant="Background"
radii="300"
onClick={onClose}
aria-label={t('Bots.privacy.close')}
>
<Icon src={Icons.Cross} />
</IconButton>
</header>
<Scroll
className={css.BodyScroll}
variant="Surface"
direction="Vertical"
size="300"
hideTrack
visibility="Hover"
>
<div className={css.Body}>
<span className={css.SectionBody}>{t('Bots.privacy.intro')}</span>
<Section
title={t('Bots.privacy.models_title')}
body={t('Bots.privacy.models_body')}
/>
<Section
title={t('Bots.privacy.avoid_title')}
body={t('Bots.privacy.avoid_body')}
/>
<p className={css.Consent}>{t('Bots.privacy.consent')}</p>
<div className={css.Links}>
<span className={css.LearnMore}>{t('Bots.privacy.learn_more')}</span>
<PolicyLink provider={t('Bots.privacy.xai_link')} url={XAI_PRIVACY_URL} />
<PolicyLink provider={t('Bots.privacy.gemini_link')} url={GEMINI_TERMS_URL} />
</div>
</div>
</Scroll>
</div>
</FocusTrap>
</OverlayCenter>
</Overlay>
);
}

View file

@ -1,13 +1,32 @@
import { style } from '@vanilla-extract/css'; import { 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';
// ChatGPT-style conversation surface for assistant bots. A slim top bar (bot name + "New chat" // Bridge widgets centre their body content in a 960px column (apps/widget-telegram/src/styles.css
// + history toggle) sits above a body that holds the active conversation / new-chat composer, // `.app { max-width: 960px; margin: 0 auto }`, mirrored by the host hero's `BotShell.HeroInner`).
// with a half-window history panel that slides in from the LEFT over it. Works identically on // The native AI surface matches that band so the transcript + composer sit centred on wide web
// desktop and mobile (single column; the panel is an overlay, not a second pane). // viewports instead of spreading edge-to-edge. On narrow (mobile/native) screens the cap is a
// no-op — the column just fills the width, so native is byte-identical to before.
const BOT_CONTENT_MAX = toRem(960);
// ChatGPT-style conversation surface for assistant bots. A slim hero (in AiChatHeader) sits above
// a body that holds the active conversation / new-chat composer, with a chat-history panel that
// slides in from the RIGHT over it. Works identically on desktop and mobile (single column; the
// panel is an overlay, not a second pane).
export const Surface = style({ export const Surface = style({
height: '100%', // Fill the page slot via FLEX, NOT `height: 100%`. The real parent is PageRoot's `<Box grow="Yes">`
width: '100%', // (Page.tsx) — a flex ROW — so `flexGrow: 1` supplies the WIDTH and `alignSelf: 'stretch'` supplies
// the HEIGHT via cross-axis stretch (height stays `auto`). An explicit `height: 100%` instead opts
// the item OUT of stretch and resolves as a PERCENTAGE of the parent's height — unreliable on
// Android WebView when an ancestor's height is itself a flex-stretch value rather than a concrete
// px (e.g. inside the swipe-back overlay card). That percentage path is what collapsed the bot
// surface to content height on native (hint+composer riding to the top, history panel cropped),
// while RoomView — which fills purely by flex — was fine. Filling by flex is robust to whatever
// positioned/flex wrapper hosts the surface.
flexGrow: 1,
minWidth: 0,
minHeight: 0,
alignSelf: 'stretch',
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
backgroundColor: color.SurfaceVariant.Container, backgroundColor: color.SurfaceVariant.Container,
@ -25,36 +44,40 @@ export const Body = style({
}); });
export const Main = style({ export const Main = style({
// No `height: 100%` here — Main is a flex child of the row-flex `Body`, so its height comes from
// cross-axis stretch (same reasoning as `Surface` above). An explicit percentage height would be
// the brittle pattern we just removed from `Surface`.
flexGrow: 1, flexGrow: 1,
minWidth: 0, minWidth: 0,
height: '100%',
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
}); });
// Half-window history panel, anchored to the RIGHT edge of the body (the left is the hero's // Chat-history panel, anchored to the RIGHT edge of the body, slid off-screen until toggled. Sits
// back affordance), slid off-screen until toggled. Sits above the Main content (zIndex 2) over // above the Main content (zIndex 2) over a click-to-close Backdrop (zIndex 1). Raised
// a click-to-close Backdrop (zIndex 1). // Surface.Container tone + a soft cast shadow lift it off the surface so the open/close reads as a
// panel sliding in, not a flat colour swap. (The back affordance lives in the top AiChatHeader.)
export const History = style({ export const History = style({
position: 'absolute', position: 'absolute',
insetBlock: 0, insetBlock: 0,
right: 0, right: 0,
zIndex: 2, zIndex: 2,
width: '50%', width: '50%',
maxWidth: toRem(420), maxWidth: toRem(440),
minWidth: toRem(260), minWidth: toRem(300),
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
backgroundColor: color.Surface.Container, backgroundColor: color.Surface.Container,
borderLeft: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`, borderLeft: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
boxShadow: '-12px 0 40px rgba(0, 0, 0, 0.32)',
transform: 'translateX(100%)', transform: 'translateX(100%)',
transition: 'transform 200ms ease', transition: 'transform 220ms cubic-bezier(0.22, 1, 0.36, 1)',
selectors: { selectors: {
'&[data-open]': { transform: 'translateX(0)' }, '&[data-open]': { transform: 'translateX(0)' },
}, },
'@media': { '@media': {
'(prefers-reduced-motion: reduce)': { transition: 'none' }, '(prefers-reduced-motion: reduce)': { transition: 'none' },
'(max-width: 600px)': { width: '85%', maxWidth: 'none' }, '(max-width: 600px)': { width: '86%', maxWidth: 'none' },
}, },
}); });
@ -63,21 +86,50 @@ export const HistoryHeader = style({
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: config.space.S200, gap: config.space.S200,
padding: `${config.space.S200} ${config.space.S200} ${config.space.S200} ${config.space.S400}`, padding: `${config.space.S300} ${config.space.S300} ${config.space.S300} ${config.space.S400}`,
borderBottom: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
}); });
export const HistoryTitle = style({ export const HistoryTitle = style({
flexGrow: 1, flexGrow: 1,
minWidth: 0, minWidth: 0,
fontWeight: 700,
letterSpacing: '0.01em',
});
// Full-width primary action at the top of the panel — a fleet-violet pill that starts a fresh
// conversation. Pinned between the header and the scrolling list so it's always reachable.
export const NewChatButton = style({
flexShrink: 0,
margin: `0 ${config.space.S300} ${config.space.S200}`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: config.space.S200,
padding: `${config.space.S300} ${config.space.S400}`,
borderRadius: config.radii.R400,
border: 'none',
appearance: 'none',
cursor: 'pointer',
font: 'inherit',
fontWeight: 600, fontWeight: 600,
backgroundColor: color.Primary.Main,
color: color.Primary.OnMain,
transition: 'filter 120ms ease, transform 120ms ease',
selectors: {
'&:hover': { filter: 'brightness(1.06)' },
'&:active': { transform: 'translateY(1px)' },
'&:focus-visible': {
outline: `${config.borderWidth.B400} solid ${color.Primary.Main}`,
outlineOffset: toRem(2),
},
},
}); });
export const HistoryList = style({ export const HistoryList = style({
flexGrow: 1, flexGrow: 1,
minHeight: 0, minHeight: 0,
overflowY: 'auto', overflowY: 'auto',
padding: config.space.S200, padding: `0 ${config.space.S300} ${config.space.S300}`,
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
gap: config.space.S100, gap: config.space.S100,
@ -92,15 +144,21 @@ export const Backdrop = style({
margin: 0, margin: 0,
appearance: 'none', appearance: 'none',
cursor: 'default', cursor: 'default',
backgroundColor: 'rgba(0, 0, 0, 0.4)', // Gentler scrim than the stock 0.4 — the panel's own shadow already separates it from the
// surface, so the chat behind only needs a light dim, not a heavy black-out.
backgroundColor: 'rgba(0, 0, 0, 0.28)',
}); });
// History row: a rounded card holding the conversation title and a muted last-activity time.
// Hover lifts the fill; the open conversation gets an active fill plus a fleet left-accent drawn
// with an inset box-shadow (so it doesn't shift the text the way a border would).
export const Row = style({ export const Row = style({
display: 'flex', display: 'flex',
alignItems: 'center', flexDirection: 'column',
gap: config.space.S200, alignItems: 'flex-start',
gap: toRem(2),
width: '100%', width: '100%',
padding: config.space.S300, padding: `${config.space.S300} ${config.space.S300}`,
borderRadius: config.radii.R400, borderRadius: config.radii.R400,
border: 'none', border: 'none',
background: 'transparent', background: 'transparent',
@ -110,9 +168,13 @@ export const Row = style({
appearance: 'none', appearance: 'none',
font: 'inherit', font: 'inherit',
minWidth: 0, minWidth: 0,
transition: 'background-color 120ms ease, box-shadow 120ms ease',
selectors: { selectors: {
'&:hover': { backgroundColor: color.Surface.ContainerHover }, '&:hover': { backgroundColor: color.Surface.ContainerHover },
'&[data-active]': { backgroundColor: color.Surface.ContainerActive }, '&[data-active]': {
backgroundColor: color.Surface.ContainerActive,
boxShadow: `inset ${toRem(3)} 0 0 ${color.Primary.Main}`,
},
'&:focus-visible': { '&:focus-visible': {
outline: `${config.borderWidth.B400} solid ${color.Primary.Main}`, outline: `${config.borderWidth.B400} solid ${color.Primary.Main}`,
outlineOffset: toRem(1), outlineOffset: toRem(1),
@ -121,16 +183,19 @@ export const Row = style({
}, },
}); });
export const RowBody = style({
flexGrow: 1,
minWidth: 0,
});
export const RowTitle = style({ export const RowTitle = style({
display: 'block', display: 'block',
width: '100%',
overflow: 'hidden', overflow: 'hidden',
textOverflow: 'ellipsis', textOverflow: 'ellipsis',
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
fontWeight: 500,
});
export const RowTime = style({
color: color.SurfaceVariant.OnContainer,
opacity: 0.6,
fontVariantNumeric: 'tabular-nums',
}); });
export const Empty = style({ export const Empty = style({
@ -165,8 +230,23 @@ export const NewChatHint = style({
// Wrapper for the standard RoomInput in the new-chat view. Mirrors ThreadDrawer's ThreadComposer // Wrapper for the standard RoomInput in the new-chat view. Mirrors ThreadDrawer's ThreadComposer
// geometry (the `ChatComposer` marker class applied alongside carries the rounded-card chrome via // geometry (the `ChatComposer` marker class applied alongside carries the rounded-card chrome via
// globalStyle in RoomView.css), so the new-chat composer looks identical to the in-conversation one. // globalStyle in RoomView.css), so the new-chat composer looks identical to the in-conversation
// one. Centred in the 960px bridge band so the composer width matches the transcript on web.
export const ComposerWrap = style({ export const ComposerWrap = style({
flexShrink: 0, flexShrink: 0,
padding: `0 ${config.space.S400} ${config.space.S400}`, width: '100%',
maxWidth: BOT_CONTENT_MAX,
marginLeft: 'auto',
marginRight: 'auto',
boxSizing: 'border-box',
// Mirror ThreadComposerAssistant's geometry EXACTLY (12px horseshoe-gap on mobile, 40px hero
// gutter on desktop) so the composer doesn't shift sideways when the new-chat view hands off to
// an opened conversation — on phones too, not just desktop.
padding: `0 ${toRem(VOJO_HORSESHOE_GAP_PX)} ${toRem(VOJO_HORSESHOE_GAP_PX)}`,
'@media': {
'screen and (min-width: 600px)': {
paddingLeft: toRem(40),
paddingRight: toRem(40),
},
},
}); });

View file

@ -9,11 +9,17 @@ import { ThreadDrawer } from '../room/ThreadDrawer';
import { RoomInput } from '../room/RoomInput'; import { RoomInput } from '../room/RoomInput';
import { ChatComposer } from '../room/RoomView.css'; import { ChatComposer } from '../room/RoomView.css';
import { getBotPath, getBotThreadPath } from '../../pages/pathUtils'; import { getBotPath, getBotThreadPath } from '../../pages/pathUtils';
import { today, timeHourMinute, timeDayMonYear } from '../../utils/time';
import { useBotConversations } from './useBotConversations'; import { useBotConversations } from './useBotConversations';
import { AiChatHeader } from './AiChatHeader'; import { AiChatHeader } from './AiChatHeader';
import type { BotPreset } from './catalog'; import type { BotPreset } from './catalog';
import * as css from './BotConversations.css'; import * as css from './BotConversations.css';
// Compact last-activity stamp for a history row: time-of-day for today's chats, date otherwise.
// Reuses the shared time helpers so it follows the user's 12/24h + locale settings.
const formatConversationTime = (ts: number): string =>
today(ts) ? timeHourMinute(ts) : timeDayMonYear(ts);
type BotConversationsProps = { type BotConversationsProps = {
preset: BotPreset; preset: BotPreset;
room: Room; room: Room;
@ -86,13 +92,29 @@ export function BotConversations({ preset, room, rootId }: BotConversationsProps
setHistoryOpen(false); setHistoryOpen(false);
}, [rootId]); }, [rootId]);
// Keep the CLOSED history panel out of the tab order and the a11y tree. The <aside> stays mounted
// so it can slide, but while closed it's only translated off-screen — without this its ✕ /
// New-chat / conversation-row buttons stay keyboard-focusable behind the chat. Set via ref
// because React 18.2's JSX typing doesn't expose `inert` (same technique as MobileTabsLayout's
// BaseLayer); `inert` removes the subtree from focus, hit-testing and assistive tech at once.
const historyPanelRef = useRef<HTMLElement>(null);
useEffect(() => {
if (historyPanelRef.current) historyPanelRef.current.inert = !historyOpen;
}, [historyOpen]);
const goNewChat = useCallback(() => { const goNewChat = useCallback(() => {
setHistoryOpen(false); setHistoryOpen(false);
navigate(getBotPath(preset.id)); navigate(getBotPath(preset.id));
}, [navigate, preset.id]); }, [navigate, preset.id]);
const openConversation = useCallback( const openConversation = useCallback(
(rid: string) => navigate(getBotThreadPath(preset.id, rid)), (rid: string) => {
// Close the panel directly rather than leaning on the rootId effect: re-tapping the
// already-open conversation doesn't change rootId, so the effect wouldn't fire and the
// panel would stay open over the chat the user just asked to see.
setHistoryOpen(false);
navigate(getBotThreadPath(preset.id, rid));
},
[navigate, preset.id] [navigate, preset.id]
); );
@ -120,7 +142,7 @@ export function BotConversations({ preset, room, rootId }: BotConversationsProps
onClick={() => setHistoryOpen(false)} onClick={() => setHistoryOpen(false)}
/> />
)} )}
<aside className={css.History} data-open={historyOpen || undefined}> <aside ref={historyPanelRef} className={css.History} data-open={historyOpen || undefined}>
<div className={css.HistoryHeader}> <div className={css.HistoryHeader}>
<Text className={css.HistoryTitle} size="H4" truncate> <Text className={css.HistoryTitle} size="H4" truncate>
{t('Bots.conversations.title')} {t('Bots.conversations.title')}
@ -128,12 +150,17 @@ export function BotConversations({ preset, room, rootId }: BotConversationsProps
<IconButton <IconButton
size="300" size="300"
variant="Background" variant="Background"
radii="300"
onClick={() => setHistoryOpen(false)} onClick={() => setHistoryOpen(false)}
aria-label={t('Bots.conversations.back')} aria-label={t('Bots.conversations.back')}
> >
<Icon src={Icons.Cross} /> <Icon src={Icons.Cross} />
</IconButton> </IconButton>
</div> </div>
<button type="button" className={css.NewChatButton} onClick={goNewChat}>
<Icon size="100" src={Icons.Plus} filled />
<span>{t('Bots.conversations.new_chat')}</span>
</button>
<div className={css.HistoryList}> <div className={css.HistoryList}>
{conversations.length === 0 ? ( {conversations.length === 0 ? (
<div className={css.Empty}> <div className={css.Empty}>
@ -150,12 +177,14 @@ export function BotConversations({ preset, room, rootId }: BotConversationsProps
data-active={conversation.rootId === rootId || undefined} data-active={conversation.rootId === rootId || undefined}
onClick={() => openConversation(conversation.rootId)} onClick={() => openConversation(conversation.rootId)}
> >
<Icon size="100" src={Icons.Message} /> <Text className={css.RowTitle} as="span" size="T300">
<span className={css.RowBody}> {conversation.title || t('Bots.conversations.untitled')}
<Text className={css.RowTitle} as="span" size="T300"> </Text>
{conversation.title || t('Bots.conversations.untitled')} {conversation.ts > 0 && (
<Text className={css.RowTime} as="span" size="T200">
{formatConversationTime(conversation.ts)}
</Text> </Text>
</span> )}
</button> </button>
)) ))
)} )}

View file

@ -172,10 +172,33 @@ export const ThreadDrawerContent = style({
// Assistant transcript variant: the channels `<Message>` hover action bar (which the S700 top // Assistant transcript variant: the channels `<Message>` hover action bar (which the S700 top
// pad above clears) never renders here (assistant rows are plain divs), so the 32px would just be // pad above clears) never renders here (assistant rows are plain divs), so the 32px would just be
// dead space under the header. Tighten it to a small, comfortable gap. // dead space under the header. Tighten it to a small, comfortable gap.
//
// Centring: the AI surface mounts this drawer with `variant="mobile"` even on desktop (it fills
// the bot content column, not a side pane), so on a wide web viewport the transcript would spread
// edge-to-edge — the user complaint that messages are "thrown into the corners". Cap it to the
// 960px bridge band (apps/widget-telegram `.app`) and centre it, with the same 40px desktop gutter
// the host hero uses (BotShell.HeroInner) so the bot's text lines up under the hero name. The row
// styles below drop their own horizontal padding so the gutter lives only here. On narrow screens
// the cap is inert and the 16px mobile gutter matches the previous layout — native is unchanged.
export const ThreadDrawerContentAssistant = style([ export const ThreadDrawerContentAssistant = style([
ThreadDrawerContent, ThreadDrawerContent,
{ {
paddingTop: config.space.S400, paddingTop: config.space.S400,
width: '100%',
maxWidth: toRem(960),
marginLeft: 'auto',
marginRight: 'auto',
boxSizing: 'border-box',
// 12px horseshoe-gap on mobile (aligns the bot text with the hero name + the composer, which
// both use it), 40px hero gutter on desktop. Matches ThreadComposerAssistant / ComposerWrap.
paddingLeft: toRem(VOJO_HORSESHOE_GAP_PX),
paddingRight: toRem(VOJO_HORSESHOE_GAP_PX),
'@media': {
'screen and (min-width: 600px)': {
paddingLeft: toRem(40),
paddingRight: toRem(40),
},
},
}, },
]); ]);
@ -243,6 +266,27 @@ export const ThreadComposer = style({
padding: `0 ${toRem(VOJO_HORSESHOE_GAP_PX)} ${toRem(VOJO_HORSESHOE_GAP_PX)}`, padding: `0 ${toRem(VOJO_HORSESHOE_GAP_PX)} ${toRem(VOJO_HORSESHOE_GAP_PX)}`,
}); });
// Assistant (AI bot) composer: centred in the same 960px bridge band as the transcript so the
// input form width matches the messages on wide web viewports (the user's "input form sized to the
// bridge content width" ask). Desktop gets the 40px hero gutter; mobile/native keep the 12px
// horseshoe-void padding the base ThreadComposer uses, so native is unchanged.
export const ThreadComposerAssistant = style([
ThreadComposer,
{
width: '100%',
maxWidth: toRem(960),
marginLeft: 'auto',
marginRight: 'auto',
boxSizing: 'border-box',
'@media': {
'screen and (min-width: 600px)': {
paddingLeft: toRem(40),
paddingRight: toRem(40),
},
},
},
]);
// Bubble chrome itself lives in `Channel.css.ts` and applies via the // Bubble chrome itself lives in `Channel.css.ts` and applies via the
// row's `data-bubble="true"` marker (set by `ChannelLayout` when // row's `data-bubble="true"` marker (set by `ChannelLayout` when
// `headerInBubble` is enabled). Both the thread drawer and the // `headerInBubble` is enabled). Both the thread drawer and the
@ -265,18 +309,21 @@ export const ThreadErrorState = style({
// Used when ThreadDrawer is mounted with messageStyle="assistant" (the AI bot surface). // Used when ThreadDrawer is mounted with messageStyle="assistant" (the AI bot surface).
// The bot's turn is full-width plain text; the user's turn is a right-aligned bubble. // The bot's turn is full-width plain text; the user's turn is a right-aligned bubble.
// Bot reply: full-width, no avatar/name/timestamp. Just the rendered body on the surface. // Bot reply: no avatar/name/timestamp, just the rendered body. The horizontal gutter is owned by
// `ThreadDrawerContentAssistant` (the centred 960px band). The text column itself is capped to a
// ~768px reading measure (~70-75ch, ChatGPT-style) and left-aligned within the band, so long bot
// prose doesn't stretch to 100+ chars/line on a wide desktop. On mobile the band is already < 768
// so the cap is inert. User bubbles (right-aligned) keep the full band width.
export const AssistantBotRow = style({ export const AssistantBotRow = style({
width: '100%', width: '100%',
padding: `0 ${config.space.S400}`, maxWidth: toRem(768),
color: color.Surface.OnContainer, color: color.Surface.OnContainer,
}); });
// User turn: right-aligned row holding a bubble. // User turn: right-aligned row holding a bubble. Gutter lives on the centred content band above.
export const AssistantUserRow = style({ export const AssistantUserRow = style({
display: 'flex', display: 'flex',
justifyContent: 'flex-end', justifyContent: 'flex-end',
padding: `0 ${config.space.S400}`,
}); });
export const AssistantUserBubble = style({ export const AssistantUserBubble = style({

View file

@ -1407,7 +1407,11 @@ export function ThreadDrawer({
{renderBody()} {renderBody()}
</Scroll> </Scroll>
</div> </div>
<div className={`${css.ThreadComposer} ${ChatComposer}`}> <div
className={`${
assistantStyle ? css.ThreadComposerAssistant : css.ThreadComposer
} ${ChatComposer}`}
>
{canMessage ? ( {canMessage ? (
<RoomInput <RoomInput
room={room} room={room}