diff --git a/public/locales/en.json b/public/locales/en.json index 64ea04fb..b68b227c 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -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.", diff --git a/public/locales/ru.json b/public/locales/ru.json index d17edc0a..ef4e531d 100644 --- a/public/locales/ru.json +++ b/public/locales/ru.json @@ -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.", diff --git a/src/app/features/bots/AiChatHeader.tsx b/src/app/features/bots/AiChatHeader.tsx index 72c0678c..6ca0930c 100644 --- a/src/app/features/bots/AiChatHeader.tsx +++ b/src/app/features/bots/AiChatHeader.tsx @@ -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(); + 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)} /> } /> + + {privacyOpen && setPrivacyOpen(false)} />} ); } diff --git a/src/app/features/bots/AiChatMenu.tsx b/src/app/features/bots/AiChatMenu.tsx index 96d7a98a..d804df88 100644 --- a/src/app/features/bots/AiChatMenu.tsx +++ b/src/app/features/bots/AiChatMenu.tsx @@ -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( - ({ 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( }; return ( - + { @@ -104,6 +106,19 @@ export const AiChatMenu = forwardRef( )} + { + onOpenPrivacy(); + requestClose(); + }} + size="300" + after={} + radii="300" + > + + {t('Bots.privacy.menu')} + + diff --git a/src/app/features/bots/AiChatPrivacy.css.ts b/src/app/features/bots/AiChatPrivacy.css.ts new file mode 100644 index 00000000..b75c5690 --- /dev/null +++ b/src/app/features/bots/AiChatPrivacy.css.ts @@ -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', + }, + }, +}); diff --git a/src/app/features/bots/AiChatPrivacy.tsx b/src/app/features/bots/AiChatPrivacy.tsx new file mode 100644 index 00000000..8bb135ce --- /dev/null +++ b/src/app/features/bots/AiChatPrivacy.tsx @@ -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 ( + + {title} + {body} + + ); +} + +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 ( + + {provider} + {url} + + ); +} + +// 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 ( + }> + + +
+
+ +
+ {t('Bots.privacy.title')} + {t('Bots.privacy.subtitle')} +
+ + + +
+ + +
+ {t('Bots.privacy.intro')} +
+
+ +

{t('Bots.privacy.consent')}

+ +
+ {t('Bots.privacy.learn_more')} + + +
+
+
+
+
+
+
+ ); +} diff --git a/src/app/features/bots/BotConversations.css.ts b/src/app/features/bots/BotConversations.css.ts index 4494e7e8..18a2a1db 100644 --- a/src/app/features/bots/BotConversations.css.ts +++ b/src/app/features/bots/BotConversations.css.ts @@ -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 `` + // (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), + }, + }, }); diff --git a/src/app/features/bots/BotConversations.tsx b/src/app/features/bots/BotConversations.tsx index da1117f8..7d474e51 100644 --- a/src/app/features/bots/BotConversations.tsx +++ b/src/app/features/bots/BotConversations.tsx @@ -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