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:
parent
58665921a4
commit
77959167fa
10 changed files with 550 additions and 45 deletions
|
|
@ -953,6 +953,21 @@
|
|||
"composer_placeholder": "Message Vojo AI…",
|
||||
"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": {
|
||||
"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.",
|
||||
|
|
|
|||
|
|
@ -971,6 +971,21 @@
|
|||
"composer_placeholder": "Сообщение для Vojo AI…",
|
||||
"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": {
|
||||
"telegram": "Подключите Telegram к Vojo: личные чаты и группы появятся в списке чатов, а ответы из приложения Vojo будут отправляться в Telegram как обычные сообщения.",
|
||||
"discord": "Подключите Discord к Vojo: личные чаты и серверы появятся в списке чатов, а ответы из приложения Vojo будут отправляться в Discord как обычные сообщения. Вход — через QR-код из мобильного Discord.",
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import FocusTrap from 'focus-trap-react';
|
|||
import { useTranslation } from 'react-i18next';
|
||||
import type { BotPreset } from './catalog';
|
||||
import { AiChatMenu } from './AiChatMenu';
|
||||
import { AiChatPrivacy } from './AiChatPrivacy';
|
||||
import { BackRouteHandler } from '../../components/BackRouteHandler';
|
||||
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
||||
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
|
||||
// (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) {
|
||||
const { t } = useTranslation();
|
||||
const mx = useMatrixClient();
|
||||
|
|
@ -41,6 +43,7 @@ export function AiChatHeader({ preset, room, onNewChat, onOpenHistory }: AiChatH
|
|||
const screenSize = useScreenSizeContext();
|
||||
const isMobile = screenSize === ScreenSize.Mobile;
|
||||
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
|
||||
const [privacyOpen, setPrivacyOpen] = useState(false);
|
||||
|
||||
const avatarMxc = room.getMember(preset.mxid)?.getMxcAvatarUrl();
|
||||
const avatarUrl = avatarMxc
|
||||
|
|
@ -133,10 +136,13 @@ export function AiChatHeader({ preset, room, onNewChat, onOpenHistory }: AiChatH
|
|||
requestClose={() => setMenuAnchor(undefined)}
|
||||
onNewChat={onNewChat}
|
||||
onOpenHistory={onOpenHistory}
|
||||
onOpenPrivacy={() => setPrivacyOpen(true)}
|
||||
/>
|
||||
</FocusTrap>
|
||||
}
|
||||
/>
|
||||
|
||||
{privacyOpen && <AiChatPrivacy onClose={() => setPrivacyOpen(false)} />}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,15 +22,17 @@ type AiChatMenuProps = {
|
|||
requestClose: () => void;
|
||||
onNewChat: () => void;
|
||||
onOpenHistory: () => void;
|
||||
onOpenPrivacy: () => void;
|
||||
};
|
||||
|
||||
// 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
|
||||
// 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
|
||||
// can't drift from the rest of the app.
|
||||
export const AiChatMenu = forwardRef<HTMLDivElement, AiChatMenuProps>(
|
||||
({ room, requestClose, onNewChat, onOpenHistory }, ref) => {
|
||||
({ room, requestClose, onNewChat, onOpenHistory, onOpenPrivacy }, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const mx = useMatrixClient();
|
||||
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
||||
|
|
@ -44,7 +46,7 @@ export const AiChatMenu = forwardRef<HTMLDivElement, AiChatMenuProps>(
|
|||
};
|
||||
|
||||
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 }}>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
|
|
@ -104,6 +106,19 @@ export const AiChatMenu = forwardRef<HTMLDivElement, AiChatMenuProps>(
|
|||
</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 }}>
|
||||
|
|
|
|||
156
src/app/features/bots/AiChatPrivacy.css.ts
Normal file
156
src/app/features/bots/AiChatPrivacy.css.ts
Normal 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',
|
||||
},
|
||||
},
|
||||
});
|
||||
138
src/app/features/bots/AiChatPrivacy.tsx
Normal file
138
src/app/features/bots/AiChatPrivacy.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,13 +1,32 @@
|
|||
import { style } from '@vanilla-extract/css';
|
||||
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"
|
||||
// + history toggle) sits above a body that holds the active conversation / new-chat composer,
|
||||
// with a half-window history panel that slides in from the LEFT over it. Works identically on
|
||||
// desktop and mobile (single column; the panel is an overlay, not a second pane).
|
||||
// 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`).
|
||||
// The native AI surface matches that band so the transcript + composer sit centred on wide web
|
||||
// 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({
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
// Fill the page slot via FLEX, NOT `height: 100%`. The real parent is PageRoot's `<Box grow="Yes">`
|
||||
// (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',
|
||||
flexDirection: 'column',
|
||||
backgroundColor: color.SurfaceVariant.Container,
|
||||
|
|
@ -25,36 +44,40 @@ export const Body = 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,
|
||||
minWidth: 0,
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
});
|
||||
|
||||
// Half-window history panel, anchored to the RIGHT edge of the body (the left is the hero's
|
||||
// back affordance), slid off-screen until toggled. Sits above the Main content (zIndex 2) over
|
||||
// a click-to-close Backdrop (zIndex 1).
|
||||
// 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
|
||||
// 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({
|
||||
position: 'absolute',
|
||||
insetBlock: 0,
|
||||
right: 0,
|
||||
zIndex: 2,
|
||||
width: '50%',
|
||||
maxWidth: toRem(420),
|
||||
minWidth: toRem(260),
|
||||
maxWidth: toRem(440),
|
||||
minWidth: toRem(300),
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
backgroundColor: color.Surface.Container,
|
||||
borderLeft: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
|
||||
boxShadow: '-12px 0 40px rgba(0, 0, 0, 0.32)',
|
||||
transform: 'translateX(100%)',
|
||||
transition: 'transform 200ms ease',
|
||||
transition: 'transform 220ms cubic-bezier(0.22, 1, 0.36, 1)',
|
||||
selectors: {
|
||||
'&[data-open]': { transform: 'translateX(0)' },
|
||||
},
|
||||
'@media': {
|
||||
'(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',
|
||||
alignItems: 'center',
|
||||
gap: config.space.S200,
|
||||
padding: `${config.space.S200} ${config.space.S200} ${config.space.S200} ${config.space.S400}`,
|
||||
borderBottom: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
|
||||
padding: `${config.space.S300} ${config.space.S300} ${config.space.S300} ${config.space.S400}`,
|
||||
});
|
||||
|
||||
export const HistoryTitle = style({
|
||||
flexGrow: 1,
|
||||
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,
|
||||
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({
|
||||
flexGrow: 1,
|
||||
minHeight: 0,
|
||||
overflowY: 'auto',
|
||||
padding: config.space.S200,
|
||||
padding: `0 ${config.space.S300} ${config.space.S300}`,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: config.space.S100,
|
||||
|
|
@ -92,15 +144,21 @@ export const Backdrop = style({
|
|||
margin: 0,
|
||||
appearance: 'none',
|
||||
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({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: config.space.S200,
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-start',
|
||||
gap: toRem(2),
|
||||
width: '100%',
|
||||
padding: config.space.S300,
|
||||
padding: `${config.space.S300} ${config.space.S300}`,
|
||||
borderRadius: config.radii.R400,
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
|
|
@ -110,9 +168,13 @@ export const Row = style({
|
|||
appearance: 'none',
|
||||
font: 'inherit',
|
||||
minWidth: 0,
|
||||
transition: 'background-color 120ms ease, box-shadow 120ms ease',
|
||||
selectors: {
|
||||
'&: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': {
|
||||
outline: `${config.borderWidth.B400} solid ${color.Primary.Main}`,
|
||||
outlineOffset: toRem(1),
|
||||
|
|
@ -121,16 +183,19 @@ export const Row = style({
|
|||
},
|
||||
});
|
||||
|
||||
export const RowBody = style({
|
||||
flexGrow: 1,
|
||||
minWidth: 0,
|
||||
});
|
||||
|
||||
export const RowTitle = style({
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
fontWeight: 500,
|
||||
});
|
||||
|
||||
export const RowTime = style({
|
||||
color: color.SurfaceVariant.OnContainer,
|
||||
opacity: 0.6,
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
});
|
||||
|
||||
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
|
||||
// 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({
|
||||
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),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -9,11 +9,17 @@ import { ThreadDrawer } from '../room/ThreadDrawer';
|
|||
import { RoomInput } from '../room/RoomInput';
|
||||
import { ChatComposer } from '../room/RoomView.css';
|
||||
import { getBotPath, getBotThreadPath } from '../../pages/pathUtils';
|
||||
import { today, timeHourMinute, timeDayMonYear } from '../../utils/time';
|
||||
import { useBotConversations } from './useBotConversations';
|
||||
import { AiChatHeader } from './AiChatHeader';
|
||||
import type { BotPreset } from './catalog';
|
||||
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 = {
|
||||
preset: BotPreset;
|
||||
room: Room;
|
||||
|
|
@ -86,13 +92,29 @@ export function BotConversations({ preset, room, rootId }: BotConversationsProps
|
|||
setHistoryOpen(false);
|
||||
}, [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(() => {
|
||||
setHistoryOpen(false);
|
||||
navigate(getBotPath(preset.id));
|
||||
}, [navigate, preset.id]);
|
||||
|
||||
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]
|
||||
);
|
||||
|
||||
|
|
@ -120,7 +142,7 @@ export function BotConversations({ preset, room, rootId }: BotConversationsProps
|
|||
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}>
|
||||
<Text className={css.HistoryTitle} size="H4" truncate>
|
||||
{t('Bots.conversations.title')}
|
||||
|
|
@ -128,12 +150,17 @@ export function BotConversations({ preset, room, rootId }: BotConversationsProps
|
|||
<IconButton
|
||||
size="300"
|
||||
variant="Background"
|
||||
radii="300"
|
||||
onClick={() => setHistoryOpen(false)}
|
||||
aria-label={t('Bots.conversations.back')}
|
||||
>
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</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}>
|
||||
{conversations.length === 0 ? (
|
||||
<div className={css.Empty}>
|
||||
|
|
@ -150,12 +177,14 @@ export function BotConversations({ preset, room, rootId }: BotConversationsProps
|
|||
data-active={conversation.rootId === rootId || undefined}
|
||||
onClick={() => openConversation(conversation.rootId)}
|
||||
>
|
||||
<Icon size="100" src={Icons.Message} />
|
||||
<span className={css.RowBody}>
|
||||
<Text className={css.RowTitle} as="span" size="T300">
|
||||
{conversation.title || t('Bots.conversations.untitled')}
|
||||
<Text className={css.RowTitle} as="span" size="T300">
|
||||
{conversation.title || t('Bots.conversations.untitled')}
|
||||
</Text>
|
||||
{conversation.ts > 0 && (
|
||||
<Text className={css.RowTime} as="span" size="T200">
|
||||
{formatConversationTime(conversation.ts)}
|
||||
</Text>
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -172,10 +172,33 @@ export const ThreadDrawerContent = style({
|
|||
// 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
|
||||
// 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([
|
||||
ThreadDrawerContent,
|
||||
{
|
||||
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)}`,
|
||||
});
|
||||
|
||||
// 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
|
||||
// row's `data-bubble="true"` marker (set by `ChannelLayout` when
|
||||
// `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).
|
||||
// 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({
|
||||
width: '100%',
|
||||
padding: `0 ${config.space.S400}`,
|
||||
maxWidth: toRem(768),
|
||||
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({
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
padding: `0 ${config.space.S400}`,
|
||||
});
|
||||
|
||||
export const AssistantUserBubble = style({
|
||||
|
|
|
|||
|
|
@ -1407,7 +1407,11 @@ export function ThreadDrawer({
|
|||
{renderBody()}
|
||||
</Scroll>
|
||||
</div>
|
||||
<div className={`${css.ThreadComposer} ${ChatComposer}`}>
|
||||
<div
|
||||
className={`${
|
||||
assistantStyle ? css.ThreadComposerAssistant : css.ThreadComposer
|
||||
} ${ChatComposer}`}
|
||||
>
|
||||
{canMessage ? (
|
||||
<RoomInput
|
||||
room={room}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue