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…",
|
"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.",
|
||||||
|
|
|
||||||
|
|
@ -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.",
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 }}>
|
||||||
|
|
|
||||||
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 { 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),
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue