From 9e0f9767296fb28a8682083d2a67f60d82d9667f Mon Sep 17 00:00:00 2001 From: 47NoahThompson <107704814+47NoahThompson@users.noreply.github.com> Date: Wed, 13 May 2026 09:13:11 -0400 Subject: [PATCH] Add widget customization and persistence (#14603) Introduce comprehensive floating widget customization: add new widget settings (title, subtitle, footer, colors, mute, streaming) with types and defaults, and expose them via EmbedDialog UI (split into Embed Setup and Widget Customization tabs). Persist and load settings through Agent page by reading/writing globals and wiring an onSaveWidgetSettings handler to setAgent; show a loading ButtonLoading for saving. Update embed iframe query params and FloatingChatWidget to honor URL params (colors, text, mute/streaming) with validation/normalization, color darkening for gradients, footer link normalization, and improved styling. Also add copy-to-clipboard in message toolbar, adjust syntax highlighter layout and Copy button, and add i18n key for muteWidget. ### What problem does this PR solve? Adds a few fields to the embed widget modal to customize the appearance of the floating widget when embedded into a page. ### Type of change - [x] New Feature (non-breaking change which adds functionality) --------- Co-authored-by: Noah --- web/src/components/embed-dialog/index.tsx | 507 ++++++++++++++++---- web/src/components/floating-chat-widget.tsx | 350 +++++++++++--- web/src/locales/en.ts | 1 + web/src/pages/agent/index.tsx | 53 +- 4 files changed, 742 insertions(+), 169 deletions(-) diff --git a/web/src/components/embed-dialog/index.tsx b/web/src/components/embed-dialog/index.tsx index d2656b2ae..dbb45df24 100644 --- a/web/src/components/embed-dialog/index.tsx +++ b/web/src/components/embed-dialog/index.tsx @@ -1,6 +1,6 @@ import CopyToClipboard from '@/components/copy-to-clipboard'; import { SelectWithSearch } from '@/components/originui/select-with-search'; -import { Button } from '@/components/ui/button'; +import { Button, ButtonLoading } from '@/components/ui/button'; import { Dialog, DialogContent, @@ -17,6 +17,7 @@ import { } from '@/components/ui/form'; import { Label } from '@/components/ui/label'; import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { SharedFrom } from '@/constants/chat'; import { LanguageAbbreviation, @@ -48,23 +49,78 @@ const FormSchema = z.object({ locale: z.string(), embedType: z.enum(['fullscreen', 'widget']), enableStreaming: z.boolean(), + muteWidget: z.boolean(), theme: z.enum([ThemeEnum.Light, ThemeEnum.Dark]), userId: z.string().optional(), + widgetTitle: z.string(), + widgetSubtitle: z.string(), + widgetFooterText: z.string(), + widgetFooterLink: z.string(), + widgetAccentColor: z.string(), + widgetBackgroundColor: z.string(), + widgetTextColor: z.string(), + widgetHeaderTextColor: z.string(), + widgetFooterTextColor: z.string(), }); +export type WidgetSettings = Pick< + z.infer, + | 'enableStreaming' + | 'muteWidget' + | 'widgetTitle' + | 'widgetSubtitle' + | 'widgetFooterText' + | 'widgetFooterLink' + | 'widgetAccentColor' + | 'widgetBackgroundColor' + | 'widgetTextColor' + | 'widgetHeaderTextColor' + | 'widgetFooterTextColor' +>; + +export const defaultWidgetSettings: WidgetSettings = { + enableStreaming: false, + muteWidget: false, + widgetTitle: '', + widgetSubtitle: '', + widgetFooterText: '', + widgetFooterLink: '', + widgetAccentColor: '#2563eb', + widgetBackgroundColor: '#ffffff', + widgetTextColor: '#111827', + widgetHeaderTextColor: '#ffffff', + widgetFooterTextColor: '#111827', +}; + type IProps = IModalProps & { token: string; from: SharedFrom; beta: string; isAgent: boolean; + initialWidgetSettings?: Partial; + onSaveWidgetSettings?: (settings: WidgetSettings) => Promise; + savingWidgetSettings?: boolean; }; +const normalizeHexColor = (value: string | undefined, fallback: string) => { + const normalizedValue = value?.trim() ?? ''; + return /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(normalizedValue) + ? normalizedValue + : fallback; +}; + +/** + * Builds the embed code preview and customization UI for shared chat and agent widgets. + */ function EmbedDialog({ hideModal, token = '', from, beta = '', isAgent, + initialWidgetSettings, + onSaveWidgetSettings, + savingWidgetSettings, visible, }: IProps) { const { t } = useTranslation(); @@ -77,8 +133,9 @@ function EmbedDialog({ published: false, locale: '', embedType: 'fullscreen' as const, - enableStreaming: false, theme: ThemeEnum.Light, + ...defaultWidgetSettings, + ...initialWidgetSettings, }, }); @@ -98,8 +155,18 @@ function EmbedDialog({ locale, embedType, enableStreaming, + muteWidget, theme, userId, + widgetTitle, + widgetSubtitle, + widgetFooterText, + widgetFooterLink, + widgetAccentColor, + widgetBackgroundColor, + widgetTextColor, + widgetHeaderTextColor, + widgetFooterTextColor, } = values; const baseRoute = embedType === 'widget' @@ -125,6 +192,39 @@ function EmbedDialog({ if (embedType === 'widget') { src.searchParams.append('mode', 'master'); src.searchParams.append('streaming', String(enableStreaming)); + src.searchParams.append('muted', String(muteWidget)); + if (!isEmpty(trim(widgetTitle))) { + src.searchParams.append('widget_title', widgetTitle ?? ''); + } + if (!isEmpty(trim(widgetSubtitle))) { + src.searchParams.append('widget_subtitle', widgetSubtitle ?? ''); + } + if (!isEmpty(trim(widgetFooterText))) { + src.searchParams.append('widget_footer', widgetFooterText ?? ''); + } + if (!isEmpty(trim(widgetFooterLink))) { + src.searchParams.append('widget_footer_link', widgetFooterLink ?? ''); + } + src.searchParams.append( + 'widget_accent_color', + normalizeHexColor(widgetAccentColor, '#2563eb'), + ); + src.searchParams.append( + 'widget_background_color', + normalizeHexColor(widgetBackgroundColor, '#ffffff'), + ); + src.searchParams.append( + 'widget_text_color', + normalizeHexColor(widgetTextColor, '#111827'), + ); + src.searchParams.append( + 'widget_header_text_color', + normalizeHexColor(widgetHeaderTextColor, '#ffffff'), + ); + src.searchParams.append( + 'widget_footer_text_color', + normalizeHexColor(widgetFooterTextColor, '#111827'), + ); } if (theme && embedType === 'fullscreen') { src.searchParams.append('theme', theme); @@ -179,9 +279,29 @@ window.addEventListener('message',e=>{ window.open(iframeSrc, '_blank'); }, [generateIframeSrc]); + const handleSaveWidgetSettings = useCallback(async () => { + if (!onSaveWidgetSettings) { + return; + } + + await onSaveWidgetSettings({ + enableStreaming: values.enableStreaming, + muteWidget: values.muteWidget, + widgetTitle: values.widgetTitle, + widgetSubtitle: values.widgetSubtitle, + widgetFooterText: values.widgetFooterText, + widgetFooterLink: values.widgetFooterLink, + widgetAccentColor: values.widgetAccentColor, + widgetBackgroundColor: values.widgetBackgroundColor, + widgetTextColor: values.widgetTextColor, + widgetHeaderTextColor: values.widgetHeaderTextColor, + widgetFooterTextColor: values.widgetFooterTextColor, + }); + }, [onSaveWidgetSettings, values]); + return ( - + {t('common.embedIntoSite')} @@ -189,103 +309,274 @@ window.addEventListener('message',e=>{
- ( - - {t('chat.embedType')} - - -
- - -
-
- - -
-
-
- -
- )} - /> - {values.embedType === 'fullscreen' && ( - ( - - {t('chat.theme')} - - -
- - -
-
- - -
-
-
- -
+ + + Embed Setup + Widget Customization + + + ( + + {t('chat.embedType')} + + +
+ + +
+
+ + +
+
+
+ +
+ )} + /> + {values.embedType === 'fullscreen' && ( + ( + + {t('chat.theme')} + + +
+ + +
+
+ + +
+
+
+ +
+ )} + /> )} - /> - )} - - {isAgent && ( - - )} - {values.embedType === 'widget' && ( - - )} - - - - {isAgent && ( - - - - )} + + {isAgent && ( + + )} + {values.embedType === 'widget' && ( + + )} + {values.embedType === 'widget' && ( + + )} + + + + {isAgent && ( + + + + )} +
+ +
+ These settings apply to the floating widget embed. +
+
+ + + + + + + + + + + + + ( + + Widget accent color + +
+ + +
+
+ +
+ )} + /> + ( + + Background color + +
+ + +
+
+ +
+ )} + /> + ( + + Text color + +
+ + +
+
+ +
+ )} + /> + ( + + Header text color + +
+ + +
+
+ +
+ )} + /> + ( + + Footer text color + +
+ + +
+
+ +
+ )} + /> +
+
+
{t('search.embedCode')} -
+
+ @@ -293,14 +584,26 @@ window.addEventListener('message',e=>{
- +
+ {isAgent && onSaveWidgetSettings && ( + + {t('flow.save')} widget settings + + )} + +
{t(isAgent ? 'flow' : 'chat', { keyPrefix: 'header' })} ID diff --git a/web/src/components/floating-chat-widget.tsx b/web/src/components/floating-chat-widget.tsx index 46fb49482..9b5c375cf 100644 --- a/web/src/components/floating-chat-widget.tsx +++ b/web/src/components/floating-chat-widget.tsx @@ -1,3 +1,4 @@ +import CopyToClipboard from '@/components/copy-to-clipboard'; import PdfSheet from '@/components/pdf-drawer'; import { useClickDrawer } from '@/components/pdf-drawer/hooks'; import { MessageType, SharedFrom } from '@/constants/chat'; @@ -14,6 +15,75 @@ import { } from '../pages/next-chats/hooks/use-send-shared-message'; import FloatingChatWidgetMarkdown from './floating-chat-widget-markdown'; +/** + * Normalizes a hex color input and falls back to a safe default when invalid. + */ +const normalizeHexColor = (value: string | null, fallback: string) => { + return value && /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(value) + ? value + : fallback; +}; + +/** + * Darkens a hex color to derive hover and gradient variants for the widget chrome. + */ +const darkenHexColor = (hexColor: string, amount = 0.12) => { + const normalizedHex = hexColor.replace('#', ''); + const expandedHex = + normalizedHex.length === 3 + ? normalizedHex + .split('') + .map((char) => `${char}${char}`) + .join('') + : normalizedHex; + const channels = expandedHex.match(/.{2}/g); + + if (!channels) { + return hexColor; + } + + return `#${channels + .map((channel) => { + const value = parseInt(channel, 16); + const adjustedValue = Math.max( + 0, + Math.min(255, Math.round(value * (1 - amount))), + ); + return adjustedValue.toString(16).padStart(2, '0'); + }) + .join('')}`; +}; + +/** + * Accepts a footer link from the widget query string and returns a safe HTTP(S) URL. + */ +const normalizeWidgetFooterLink = (value: string | null) => { + const normalizedValue = value?.trim(); + + if (!normalizedValue) { + return undefined; + } + + const candidate = /^[a-z][a-z\d+.-]*:/i.test(normalizedValue) + ? normalizedValue + : `https://${normalizedValue}`; + + try { + const url = new URL(candidate); + + if (url.protocol === 'http:' || url.protocol === 'https:') { + return url.toString(); + } + } catch { + return undefined; + } + + return undefined; +}; + +/** + * Renders the embeddable floating chat widget and applies URL-driven widget settings. + */ const FloatingChatWidget = () => { const { t } = useTranslation(); const [isOpen, setIsOpen] = useState(false); @@ -36,6 +106,34 @@ const FloatingChatWidget = () => { const urlParams = new URLSearchParams(window.location.search); const mode = urlParams.get('mode') || 'full'; // 'button', 'window', or 'full' const enableStreaming = urlParams.get('streaming') === 'true'; // Only enable if explicitly set to true + const isMuted = urlParams.get('muted') === 'true'; + const widgetTitle = urlParams.get('widget_title')?.trim(); + const widgetSubtitle = urlParams.get('widget_subtitle')?.trim(); + const widgetFooter = urlParams.get('widget_footer')?.trim(); + const widgetFooterLink = normalizeWidgetFooterLink( + urlParams.get('widget_footer_link'), + ); + const widgetAccentColor = normalizeHexColor( + urlParams.get('widget_accent_color'), + '#2563eb', + ); + const widgetAccentColorStrong = darkenHexColor(widgetAccentColor); + const widgetBackgroundColor = normalizeHexColor( + urlParams.get('widget_background_color'), + '#ffffff', + ); + const widgetTextColor = normalizeHexColor( + urlParams.get('widget_text_color'), + '#111827', + ); + const widgetHeaderTextColor = normalizeHexColor( + urlParams.get('widget_header_text_color'), + '#ffffff', + ); + const widgetFooterTextColor = normalizeHexColor( + urlParams.get('widget_footer_text_color'), + '#111827', + ); const { handlePressEnter, @@ -58,6 +156,49 @@ const FloatingChatWidget = () => { )(); const title = data.title; + const displayTitle = widgetTitle || title || t('chat.chatSupport'); + const displaySubtitle = widgetSubtitle || t('chat.replyInstantly'); + const displayFooter = widgetFooter || ''; + const renderFooter = () => { + if (!displayFooter) { + return null; + } + + return ( +
+ {widgetFooterLink ? ( + + {displayFooter} + + ) : ( + displayFooter + )} +
+ ); + }; + const bodyContainerStyle: React.CSSProperties = { + borderRadius: '0 0 16px 16px', + backgroundColor: widgetBackgroundColor, + color: widgetTextColor, + }; + const inputStyle: React.CSSProperties = { + minHeight: '44px', + maxHeight: '120px', + color: widgetTextColor, + backgroundColor: widgetBackgroundColor, + }; const { visible, hideModal, documentId, selectedChunk, clickDocumentButton } = useClickDrawer(); @@ -69,6 +210,10 @@ const FloatingChatWidget = () => { // Play sound when opening const playNotificationSound = useCallback(() => { + if (isMuted) { + return; + } + try { const audioContext = new ( window.AudioContext || (window as any).webkitAudioContext @@ -94,10 +239,14 @@ const FloatingChatWidget = () => { console.warn(error); // Silent fail if audio not supported } - }, []); + }, [isMuted]); // Play sound for AI responses (Intercom-style) const playResponseSound = useCallback(() => { + if (isMuted) { + return; + } + try { const audioContext = new ( window.AudioContext || (window as any).webkitAudioContext @@ -124,7 +273,7 @@ const FloatingChatWidget = () => { // Silent fail if audio not supported } - }, []); + }, [isMuted]); // Set loaded state and locale useEffect(() => { @@ -338,9 +487,10 @@ const FloatingChatWidget = () => { '*', ); }} - className={`w-14 h-14 bg-blue-600 hover:bg-blue-700 text-white rounded-full transition-all duration-300 flex items-center justify-center group ${ + className={`w-14 h-14 text-white rounded-full transition-all duration-300 flex items-center justify-center group ${ isOpen ? 'scale-95' : 'scale-100 hover:scale-105' }`} + style={{ backgroundColor: widgetAccentColor }} >
{