From 7e6146ec18fa2ca3b08d558c05d3f894a03b473a Mon Sep 17 00:00:00 2001 From: Rebecca Alpert Date: Fri, 8 Aug 2025 09:51:51 -0400 Subject: [PATCH 1/8] feat(Message): Add support for footnotes Styled footnotes and added demos with some onClick events. Assisted-by: Cursor (used for debugging demos, copying bot work to user message demo, and removing node from all components except table) --- .../chatbot/examples/Messages/BotMessage.tsx | 104 ++++++++++++- .../chatbot/examples/Messages/UserMessage.tsx | 122 ++++++++++++++- .../src/Message/LinkMessage/LinkMessage.tsx | 8 +- .../Message/ListMessage/ListItemMessage.tsx | 4 +- .../src/Message/ListMessage/ListMessage.scss | 15 ++ packages/module/src/Message/Message.scss | 1 + packages/module/src/Message/Message.test.tsx | 36 +++++ packages/module/src/Message/Message.tsx | 142 ++++++++++++++---- .../SuperscriptMessage.scss | 8 + .../SuperscriptMessage/SuperscriptMessage.tsx | 13 ++ .../src/Message/TextMessage/TextMessage.scss | 34 +++++ 11 files changed, 451 insertions(+), 36 deletions(-) create mode 100644 packages/module/src/Message/SuperscriptMessage/SuperscriptMessage.scss create mode 100644 packages/module/src/Message/SuperscriptMessage/SuperscriptMessage.tsx diff --git a/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/BotMessage.tsx b/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/BotMessage.tsx index 18e381d0e..3910e7290 100644 --- a/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/BotMessage.tsx +++ b/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/BotMessage.tsx @@ -1,4 +1,12 @@ -import { CSSProperties, useState, Fragment, FunctionComponent, MouseEvent, Ref } from 'react'; +import { + CSSProperties, + useState, + Fragment, + FunctionComponent, + MouseEvent as ReactMouseEvent, + Keyboard as ReactKeyboardEvent, + Ref +} from 'react'; import Message from '@patternfly/chatbot/dist/dynamic/Message'; import patternflyAvatar from './patternfly_avatar.jpg'; import squareImg from './PF-social-color-square.svg'; @@ -44,6 +52,8 @@ export const BotMessageExample: FunctionComponent = () => { return table; case 'Image': return image; + case 'Footnote': + return footnote; default: return; } @@ -150,6 +160,20 @@ _Italic text, formatted with single underscores_ const image = `![Multi-colored wavy lines on a black background](https://cdn.dribbble.com/userupload/10651749/file/original-8a07b8e39d9e8bf002358c66fce1223e.gif)`; + const footnote = `This is some text with a footnote[^1] and here's a longer one.[^bignote] + +You can also reference the same footnote multiple times[^1]. + + [^1]: This is the full footnote text. You can click the arrow to go back up. + + [^bignote]: Here's one with multiple paragraphs and **formatting**. + + Indent paragraphs to include them in the footnote. + + Add as many paragraphs as you like. You can include *italic text*, **bold text**, and even \`code\`. + + > You can even include blockquotes in footnotes!`; + const error = { title: 'Could not load chat', children: 'Wait a few minutes and check your network settings. If the issue persists: ', @@ -165,8 +189,8 @@ _Italic text, formatted with single underscores_ ) }; - const onSelect = (_event: MouseEvent | undefined, value: string | number | undefined) => { - setVariant(value); + const onSelect = (_event: ReactMouseEvent | undefined, value: string | number | undefined) => { + setVariant(value as string); setSelected(value as string); setIsOpen(false); if (value === 'Expandable code') { @@ -196,6 +220,78 @@ _Italic text, formatted with single underscores_ ); + const handleFootnoteNavigation = (event: ReactMouseEvent | ReactKeyboardEvent) => { + const target = event.target as HTMLElement; + + // Depending on whether it is a click event or keyboard event, target may be a link or something like a span + // Look for the closest anchor element (could be a parent) + const anchorElement = target.closest('a'); + const href = anchorElement?.getAttribute('href'); + + // Check if this is a footnote link - we only have internal links in this example, so this is all we need here + if (href && href.startsWith('#')) { + // Prevent default behavior to avoid page re-render on click in PatternFly docs framework + event.preventDefault(); + + let targetElement: HTMLElement | null = null; + const targetId = href.replace('#', ''); + targetElement = document.querySelector(`[id="${targetId}"]`); + + if (targetElement) { + // For footnote definitions, try to focus on the backref link inside + let focusTarget = targetElement; + + // If we found a footnote definition container, look for the backref link inside it + if (targetElement.id?.startsWith('user-content-fn-')) { + const backrefLink = targetElement.querySelector('a[data-footnote-backref]'); + if (backrefLink) { + focusTarget = backrefLink as HTMLElement; + } + } + + focusTarget.focus({ preventScroll: true }); + + let elementToHighlight = targetElement; + + // If this is a backref link (going back to footnote reference), + // we want to highlight more of the ref line and not just the link itself + // since the target is so small + if (targetElement.id?.startsWith('user-content-fnref-')) { + const refLink = targetElement; + + // Walk up the DOM to find a paragraph or other meaningful container + // This may just be a "sup" element, so we want to find the parent + let parent = refLink.parentElement; + while (parent && parent.tagName.toLowerCase() !== 'p' && parent !== document.body) { + parent = parent.parentElement; + } + + // Use the paragraph if found, otherwise use the immediate parent or target as a fallback + elementToHighlight = parent || refLink.parentElement || targetElement; + } + + // Briefly highlight the target element since we're not scrolling to it in this example + // You could also use onClick to implement scrolling to the target element if you wanted + const originalBackground = elementToHighlight.style.backgroundColor; + const originalTransition = elementToHighlight.style.transition; + + elementToHighlight.style.transition = 'background-color 0.3s ease'; + elementToHighlight.style.backgroundColor = 'var(--pf-t--global--background--color--tertiary--default)'; + + setTimeout(() => { + elementToHighlight.style.backgroundColor = originalBackground; + setTimeout(() => { + elementToHighlight.style.transition = originalTransition; + }, 300); + }, 1000); + } + } + }; + + const onClick = (event: ReactMouseEvent | ReactKeyboardEvent) => { + handleFootnoteNavigation(event); + }; + return ( <> More complex list Table Image + Footnote Error @@ -265,6 +362,7 @@ _Italic text, formatted with single underscores_ // The purpose of this plugin is to provide unique link names for the code blocks // Because they are in the same message, this requires a custom plugin to parse the syntax tree additionalRehypePlugins={[rehypeCodeBlockToggle]} + linkProps={{ onClick }} /> ); diff --git a/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/UserMessage.tsx b/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/UserMessage.tsx index 482a207ff..6053cae31 100644 --- a/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/UserMessage.tsx +++ b/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/UserMessage.tsx @@ -1,4 +1,14 @@ -import { Fragment, useState, useRef, useEffect, CSSProperties, FunctionComponent, MouseEvent, Ref } from 'react'; +import { + Fragment, + useState, + useRef, + useEffect, + CSSProperties, + FunctionComponent, + MouseEvent as ReactMouseEvent, + KeyboardEvent as ReactKeyboardEvent, + Ref +} from 'react'; import Message from '@patternfly/chatbot/dist/dynamic/Message'; import userAvatar from './user_avatar.svg'; import { @@ -64,6 +74,8 @@ export const UserMessageExample: FunctionComponent = () => { return table; case 'Image': return image; + case 'Footnote': + return footnote; default: return ''; } @@ -170,6 +182,20 @@ _Italic text, formatted with single underscores_ const image = `![Multi-colored wavy lines on a black background](https://cdn.dribbble.com/userupload/10651749/file/original-8a07b8e39d9e8bf002358c66fce1223e.gif)`; + const footnote = `This is some text with a footnote[^1] and here's a longer one.[^bignote] + +You can also reference the same footnote multiple times[^1]. + + [^1]: This is the full footnote text. You can click the arrow to go back up. + + [^bignote]: Here's one with multiple paragraphs and **formatting**. + + Indent paragraphs to include them in the footnote. + + Add as many paragraphs as you like. You can include *italic text*, **bold text**, and even \`code\`. + + > You can even include blockquotes in footnotes!`; + const error = { title: 'Could not load chat', children: 'Wait a few minutes and check your network settings. If the issue persists: ', @@ -185,7 +211,7 @@ _Italic text, formatted with single underscores_ ) }; - const onSelect = (_event: MouseEvent | undefined, value: string | number | undefined) => { + const onSelect = (_event: ReactMouseEvent | undefined, value: string | number | undefined) => { setVariant(value); setSelected(value as string); setIsOpen(false); @@ -221,6 +247,96 @@ _Italic text, formatted with single underscores_ ); + const handleFootnoteNavigation = (event: ReactMouseEvent | ReactKeyboardEvent) => { + const target = event.target as HTMLElement; + + // Depending on whether it is a click event or keyboard event, target may be a link or something like a span + // Look for the closest anchor element (could be a parent) + const anchorElement = target.closest('a'); + const href = anchorElement?.getAttribute('href'); + + // Check if this is a footnote link - we only have internal links in this example, so this is all we need here + if (href && href.startsWith('#')) { + // Prevent default behavior to avoid page re-render on click in PatternFly docs framework + event.preventDefault(); + + let targetElement: HTMLElement | null = null; + const targetId = href.replace('#', ''); + targetElement = document.querySelector(`[id="${targetId}"]`); + + if (targetElement) { + // For footnote definitions, try to focus on the backref link inside + let focusTarget = targetElement; + + // If we found a footnote definition container, look for the backref link inside it + if (targetElement.id?.startsWith('user-content-fn-')) { + const backrefLink = targetElement.querySelector('a[data-footnote-backref]'); + if (backrefLink) { + focusTarget = backrefLink as HTMLElement; + } + } + + focusTarget.focus({ preventScroll: true }); + + // For all footnote navigation, find the nearest span with class "pf-chatbot__message-text" + // to ensure we highlight the appropriate container + let elementToHighlight = targetElement; + + const searchStartElement = targetElement; + + let elementToHighlightContainer: HTMLElement | null = null; + + // If navigating to a footnote definition, look for the footnotes container + if (targetElement.id?.startsWith('user-content-fn-')) { + let parent = searchStartElement.parentElement; + while ( + parent && + !( + parent.tagName.toLowerCase() === 'div' && parent.classList.contains('pf-chatbot__message-ordered-list') + ) && + parent !== document.body + ) { + parent = parent.parentElement; + } + elementToHighlightContainer = parent; + } else { + // For footnote references, look for the message text span + let parent = searchStartElement.parentElement; + while ( + parent && + !(parent.tagName.toLowerCase() === 'span' && parent.classList.contains('pf-chatbot__message-text')) && + parent !== document.body + ) { + parent = parent.parentElement; + } + elementToHighlightContainer = parent; + } + + // Use the found container if available, otherwise fall back to the target element + elementToHighlight = elementToHighlightContainer || targetElement; + + // Briefly highlight the target element since we're not scrolling to it in this example + // You could also use onClick to implement scrolling to the target element if you wanted + const originalBackground = elementToHighlight.style.backgroundColor; + const originalTransition = elementToHighlight.style.transition; + + elementToHighlight.style.transition = 'background-color 0.3s ease'; + elementToHighlight.style.backgroundColor = 'var(--pf-t--global--icon--color--brand--hover)'; + + setTimeout(() => { + elementToHighlight.style.backgroundColor = originalBackground; + setTimeout(() => { + elementToHighlight.style.transition = originalTransition; + }, 300); + }, 1000); + } + } + }; + + const onClick = (event: ReactMouseEvent | ReactKeyboardEvent) => { + handleFootnoteNavigation(event); + }; + return ( <> More complex list Table Image + Footnote Error @@ -287,6 +404,7 @@ _Italic text, formatted with single underscores_ // The purpose of this plugin is to provide unique link names for the code blocks // Because they are in the same message, this requires a custom plugin to parse the syntax tree additionalRehypePlugins={[rehypeCodeBlockToggle]} + linkProps={{ onClick }} /> ); diff --git a/packages/module/src/Message/LinkMessage/LinkMessage.tsx b/packages/module/src/Message/LinkMessage/LinkMessage.tsx index 45091bd8b..de32e46b6 100644 --- a/packages/module/src/Message/LinkMessage/LinkMessage.tsx +++ b/packages/module/src/Message/LinkMessage/LinkMessage.tsx @@ -4,8 +4,9 @@ import { Button, ButtonProps } from '@patternfly/react-core'; import { ExternalLinkSquareAltIcon } from '@patternfly/react-icons'; +import { ExtraProps } from 'react-markdown'; -const LinkMessage = ({ children, target, href, ...props }: ButtonProps) => { +const LinkMessage = ({ children, target, href, id, ...props }: ButtonProps & ExtraProps) => { if (target === '_blank') { return ( ); diff --git a/packages/module/src/Message/ListMessage/ListItemMessage.tsx b/packages/module/src/Message/ListMessage/ListItemMessage.tsx index 74872a95c..b345d6b16 100644 --- a/packages/module/src/Message/ListMessage/ListItemMessage.tsx +++ b/packages/module/src/Message/ListMessage/ListItemMessage.tsx @@ -5,6 +5,8 @@ import { ExtraProps } from 'react-markdown'; import { ListItem } from '@patternfly/react-core'; -const ListItemMessage = ({ children }: JSX.IntrinsicElements['li'] & ExtraProps) => {children}; +const ListItemMessage = ({ children, ...props }: JSX.IntrinsicElements['li'] & ExtraProps) => ( + {children} +); export default ListItemMessage; diff --git a/packages/module/src/Message/ListMessage/ListMessage.scss b/packages/module/src/Message/ListMessage/ListMessage.scss index 3f8e109ad..8b6e7bc44 100644 --- a/packages/module/src/Message/ListMessage/ListMessage.scss +++ b/packages/module/src/Message/ListMessage/ListMessage.scss @@ -21,5 +21,20 @@ background-color: var(--pf-t--global--color--brand--default); color: var(--pf-t--global--text--color--on-brand--default); padding: var(--pf-t--global--spacer--sm); + + // prevents issues when highlighting things like footnotes - don't have blue on blue + .pf-chatbot__message-text { + background-color: initial; + } + } + + // targets footnotes specifically and prevents misalignment problems + li[id*='user-content-fn-'] > span { + display: inline-flex; + flex-direction: column; + } + + li a { + color: var(--pf-t--global--text--color--on-brand--default); } } diff --git a/packages/module/src/Message/Message.scss b/packages/module/src/Message/Message.scss index dd1c6e0c2..768f6071d 100644 --- a/packages/module/src/Message/Message.scss +++ b/packages/module/src/Message/Message.scss @@ -106,6 +106,7 @@ @import './MessageLoading'; @import './CodeBlockMessage/CodeBlockMessage'; @import './TextMessage/TextMessage'; +@import './SuperscriptMessage/SuperscriptMessage.scss'; // ============================================================================ // Information density styles diff --git a/packages/module/src/Message/Message.test.tsx b/packages/module/src/Message/Message.test.tsx index c05753475..0b3ee6c92 100644 --- a/packages/module/src/Message/Message.test.tsx +++ b/packages/module/src/Message/Message.test.tsx @@ -142,6 +142,20 @@ const EMPTY_TABLE = ` `; +const FOOTNOTE = `This is some text with a footnote[^1] and here's a longer one.[^bignote] + + You can also reference the same footnote multiple times[^1]. + + [^1]: This is the full footnote text. You can click the arrow to go back up. + + [^bignote]: Here's one with multiple paragraphs and **formatting**. + + Indent paragraphs to include them in the footnote. + + Add as many paragraphs as you like. You can include *italic text*, **bold text**, and even \`code\`. + + > You can even include blockquotes in footnotes!`; + const IMAGE = `![Multi-colored wavy lines on a black background](https://cdn.dribbble.com/userupload/10651749/file/original-8a07b8e39d9e8bf002358c66fce1223e.gif)`; const INLINE_IMAGE = `inline text ![Multi-colored wavy lines on a black background](https://cdn.dribbble.com/userupload/10651749/file/original-8a07b8e39d9e8bf002358c66fce1223e.gif)`; @@ -769,6 +783,28 @@ describe('Message', () => { render(); expect(screen.getByRole('grid', { name: /Test/i })).toBeTruthy(); }); + it('should render footnote correctly', () => { + render(); + expect(screen.getByText(/This is some text with a footnote/i)).toBeTruthy(); + expect(screen.getByText(/and here's a longer one./i)).toBeTruthy(); + expect(screen.getByText(/You can also reference the same footnote multiple times./i)).toBeTruthy(); + expect(screen.getByRole('heading', { name: /Footnotes/i })).toBeTruthy(); + expect(screen.getByText(/This is the full footnote text. You can click the arrow to go back up./i)).toBeTruthy(); + expect(screen.getByText(/Here's one with multiple paragraphs and/i)).toBeTruthy(); + expect(screen.getByText(/formatting/i)).toBeTruthy(); + expect(screen.getByText(/Indent paragraphs to include them in the footnote./i)).toBeTruthy(); + expect(screen.getByText(/Add as many paragraphs as you like. You can include/i)).toBeTruthy(); + expect(screen.getByText(/italic text/i)).toBeTruthy(); + expect(screen.getByText(/bold text/i)).toBeTruthy(); + expect(screen.getByText(/, and even/i)).toBeTruthy(); + expect(screen.getByText(/code/i)).toBeTruthy(); + expect(screen.getByText(/You can even include blockquotes in footnotes!/i)).toBeTruthy(); + expect(screen.getAllByRole('link', { name: '1' })).toHaveLength(2); + expect(screen.getAllByRole('link', { name: '2' })).toBeTruthy(); + expect(screen.getByRole('link', { name: 'Back to reference 1' })).toBeTruthy(); + expect(screen.getByRole('link', { name: 'Back to reference 1-2' })).toBeTruthy(); + expect(screen.getByRole('link', { name: /Back to reference 2/i })).toBeTruthy(); + }); it('should render beforeMainContent with main content', () => { const mainContent = 'Main message content'; const beforeMainContentText = 'Before main content'; diff --git a/packages/module/src/Message/Message.tsx b/packages/module/src/Message/Message.tsx index 067c04475..28e1b460f 100644 --- a/packages/module/src/Message/Message.tsx +++ b/packages/module/src/Message/Message.tsx @@ -51,6 +51,7 @@ import MessageInput from './MessageInput'; import { rehypeMoveImagesOutOfParagraphs } from './Plugins/rehypeMoveImagesOutOfParagraphs'; import ToolResponse, { ToolResponseProps } from '../ToolResponse'; import DeepThinking, { DeepThinkingProps } from '../DeepThinking'; +import SuperscriptMessage from './SuperscriptMessage/SuperscriptMessage'; export interface MessageAttachment { /** Name of file attached to the message */ @@ -163,6 +164,8 @@ export interface MessageProps extends Omit, 'role'> { tableProps?: Required> & TableProps; /** Additional rehype plugins passed from the consumer */ additionalRehypePlugins?: PluggableList; + /** Additional remark plugins passed from the consumer */ + additionalRemarkPlugins?: PluggableList; /** Whether to open links in message in new tab. */ openLinkInNewTab?: boolean; /** Optional inline error message that can be displayed in the message */ @@ -223,6 +226,7 @@ export const MessageBase: FunctionComponent = ({ tableProps, openLinkInNewTab = true, additionalRehypePlugins = [], + additionalRemarkPlugins = [], linkProps, error, isEditable, @@ -275,41 +279,123 @@ export const MessageBase: FunctionComponent = ({ return ( , - code: ({ children, ...props }) => ( - - {children} - - ), - h1: (props) => , - h2: (props) => , - h3: (props) => , - h4: (props) => , - h5: (props) => , - h6: (props) => , - blockquote: (props) => , - ul: (props) => , - ol: (props) => , - li: (props) => , + p: (props) => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { node, ...rest } = props; + return ; + }, + code: ({ children, ...props }) => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { node, ...codeProps } = props; + return ( + + {children} + + ); + }, + h1: (props) => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { node, ...rest } = props; + return ; + }, + h2: (props) => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { node, ...rest } = props; + return ; + }, + h3: (props) => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { node, ...rest } = props; + return ; + }, + h4: (props) => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { node, ...rest } = props; + return ; + }, + h5: (props) => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { node, ...rest } = props; + return ; + }, + h6: (props) => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { node, ...rest } = props; + return ; + }, + blockquote: (props) => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { node, ...rest } = props; + return ; + }, + ul: (props) => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { node, ...rest } = props; + return ; + }, + ol: (props) => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { node, ...rest } = props; + return ; + }, + li: (props) => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { node, ...rest } = props; + return ; + }, + // table requires node attribute for calculating headers for mobile breakpoint table: (props) => , - tbody: (props) => , - thead: (props) => , - tr: (props) => , + tbody: (props) => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { node, ...rest } = props; + return ; + }, + thead: (props) => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { node, ...rest } = props; + return ; + }, + tr: (props) => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { node, ...rest } = props; + return ; + }, td: (props) => { // Conflicts with Td type // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { width, ...rest } = props; + const { node, width, ...rest } = props; return ; }, - th: (props) => , - img: (props) => , - a: (props) => ( - - {props.children} - - ) + th: (props) => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { node, ...rest } = props; + return ; + }, + img: (props) => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { node, ...rest } = props; + return ; + }, + a: (props) => { + // node is just the details of the document structure - not needed + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { node, ...rest } = props; + return ( + // some a types conflict with ButtonProps, but it's ok because we are using an a tag + // there are too many to handle manually + + {props.children} + + ); + }, + // used for footnotes + sup: (props) => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { node, ...rest } = props; + return ; + } }} - remarkPlugins={[remarkGfm]} + remarkPlugins={[remarkGfm, ...additionalRemarkPlugins]} rehypePlugins={rehypePlugins} {...reactMarkdownProps} > diff --git a/packages/module/src/Message/SuperscriptMessage/SuperscriptMessage.scss b/packages/module/src/Message/SuperscriptMessage/SuperscriptMessage.scss new file mode 100644 index 000000000..e7102591c --- /dev/null +++ b/packages/module/src/Message/SuperscriptMessage/SuperscriptMessage.scss @@ -0,0 +1,8 @@ +.pf-chatbot__message-superscript { + font-size: smaller; + vertical-align: super; + .pf-v6-c-button.pf-m-link.pf-m-inline { + font-size: inherit; + vertical-align: inherit; + } +} diff --git a/packages/module/src/Message/SuperscriptMessage/SuperscriptMessage.tsx b/packages/module/src/Message/SuperscriptMessage/SuperscriptMessage.tsx new file mode 100644 index 000000000..1cbcc4379 --- /dev/null +++ b/packages/module/src/Message/SuperscriptMessage/SuperscriptMessage.tsx @@ -0,0 +1,13 @@ +// ============================================================================ +// Chatbot Main - Message - Content - Superscript (like for footnotes) +// ============================================================================ + +import { ExtraProps } from 'react-markdown'; + +const SuperscriptMessage = ({ children }: JSX.IntrinsicElements['sup'] & ExtraProps) => ( + + {children} + +); + +export default SuperscriptMessage; diff --git a/packages/module/src/Message/TextMessage/TextMessage.scss b/packages/module/src/Message/TextMessage/TextMessage.scss index f229660b6..6d14853c2 100644 --- a/packages/module/src/Message/TextMessage/TextMessage.scss +++ b/packages/module/src/Message/TextMessage/TextMessage.scss @@ -34,6 +34,40 @@ background-color: var(--pf-t--global--background--color--tertiary--default); font-size: var(--pf-t--global--font--size--body--default); } + + // Hide message text that contains sr-only content + // https://css-tricks.com/inclusively-hidden/ + &:has(.sr-only) { + clip: rect(0 0 0 0); + clip-path: inset(50%); + height: 1px; + overflow: hidden; + position: absolute; + white-space: nowrap; + width: 1px; + } +} + +// ============================================================================ +// Footnote spacing styles +// ============================================================================ + +// Add spacing to paragraphs in multi-paragraph footnotes +// Only target p tags that are direct children of message-text spans (not inside blockquotes, etc.) +li[id*='user-content-fn-']:has(> span > .pf-chatbot__message-text + .pf-chatbot__message-text) + > span + > .pf-chatbot__message-text + > p { + margin-block-end: var(--pf-t--global--spacer--md); +} + +// Handle user message footnotes which may have extra span wrappers +li[id*='user-content-fn-']:has(> span > span > .pf-chatbot__message-text + .pf-chatbot__message-text) + > span + > span + > .pf-chatbot__message-text + > p { + margin-block-end: var(--pf-t--global--spacer--md); } .pf-chatbot__message--user { From 0671d6bee76b1731c1b89f337fbef70017f9bf5e Mon Sep 17 00:00:00 2001 From: Rebecca Alpert Date: Wed, 13 Aug 2025 14:25:35 -0400 Subject: [PATCH 2/8] Update text Co-authored-by: Erin Donehoo <105813956+edonehoo@users.noreply.github.com> --- .../chatbot/examples/Messages/BotMessage.tsx | 10 +++++----- .../chatbot/examples/Messages/UserMessage.tsx | 10 +++++----- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/BotMessage.tsx b/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/BotMessage.tsx index 3910e7290..54f588fd9 100644 --- a/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/BotMessage.tsx +++ b/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/BotMessage.tsx @@ -160,17 +160,17 @@ _Italic text, formatted with single underscores_ const image = `![Multi-colored wavy lines on a black background](https://cdn.dribbble.com/userupload/10651749/file/original-8a07b8e39d9e8bf002358c66fce1223e.gif)`; - const footnote = `This is some text with a footnote[^1] and here's a longer one.[^bignote] + const footnote = `This is some text that has a short footnote[^1] and this is text with a longer footnote.[^bignote] You can also reference the same footnote multiple times[^1]. - [^1]: This is the full footnote text. You can click the arrow to go back up. + [^1]: This is a short footnote. To return the highlight to the original message, click the arrow. - [^bignote]: Here's one with multiple paragraphs and **formatting**. + [^bignote]: This is a long footnote with multiple paragraphs and formatting. - Indent paragraphs to include them in the footnote. + To break long footnotes into paragraphs, indent the text. - Add as many paragraphs as you like. You can include *italic text*, **bold text**, and even \`code\`. + Add as many paragraphs as you like. You can include *italic text*, **bold text**, and \`code\`. > You can even include blockquotes in footnotes!`; diff --git a/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/UserMessage.tsx b/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/UserMessage.tsx index 6053cae31..9e83c055a 100644 --- a/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/UserMessage.tsx +++ b/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/UserMessage.tsx @@ -182,17 +182,17 @@ _Italic text, formatted with single underscores_ const image = `![Multi-colored wavy lines on a black background](https://cdn.dribbble.com/userupload/10651749/file/original-8a07b8e39d9e8bf002358c66fce1223e.gif)`; - const footnote = `This is some text with a footnote[^1] and here's a longer one.[^bignote] + const footnote = `This is some text that has a short footnote[^1] and this is text with a longer footnote.[^bignote] You can also reference the same footnote multiple times[^1]. - [^1]: This is the full footnote text. You can click the arrow to go back up. + [^1]: This is a short footnote. To return the highlight to the original message, click the arrow. - [^bignote]: Here's one with multiple paragraphs and **formatting**. + [^bignote]: This is a long footnote with multiple paragraphs and formatting. - Indent paragraphs to include them in the footnote. + To break long footnotes into paragraphs, indent the text. - Add as many paragraphs as you like. You can include *italic text*, **bold text**, and even \`code\`. + Add as many paragraphs as you like. You can include *italic text*, **bold text**, and \`code\`. > You can even include blockquotes in footnotes!`; From 0aecba00de8b8288c9d6088d578b34455156f79c Mon Sep 17 00:00:00 2001 From: Rebecca Alpert Date: Wed, 20 Aug 2025 13:51:37 -0400 Subject: [PATCH 3/8] Address focus/scroll feedback --- .../chatbot/examples/Messages/BotMessage.tsx | 29 +++++++++++++++---- .../chatbot/examples/Messages/UserMessage.tsx | 27 ++++++++++++++--- 2 files changed, 47 insertions(+), 9 deletions(-) diff --git a/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/BotMessage.tsx b/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/BotMessage.tsx index 54f588fd9..76670dff8 100644 --- a/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/BotMessage.tsx +++ b/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/BotMessage.tsx @@ -4,7 +4,7 @@ import { Fragment, FunctionComponent, MouseEvent as ReactMouseEvent, - Keyboard as ReactKeyboardEvent, + KeyboardEvent as ReactKeyboardEvent, Ref } from 'react'; import Message from '@patternfly/chatbot/dist/dynamic/Message'; @@ -243,13 +243,32 @@ You can also reference the same footnote multiple times[^1]. // If we found a footnote definition container, look for the backref link inside it if (targetElement.id?.startsWith('user-content-fn-')) { - const backrefLink = targetElement.querySelector('a[data-footnote-backref]'); - if (backrefLink) { - focusTarget = backrefLink as HTMLElement; + // Check if we came from a specific footnote reference by looking at the clicked element + const clickedElement = event.target as HTMLElement; + const clickedAnchor = clickedElement.closest('a'); + + // If we clicked from a footnote reference, find the backref that points back to that specific reference + if (clickedAnchor?.id && clickedAnchor.id.startsWith('user-content-fnref-')) { + const specificBackref = targetElement.querySelector(`a[href="#${clickedAnchor.id}"]`); + if (specificBackref) { + focusTarget = specificBackref as HTMLElement; + } else { + // Fallback to any backref link + const backrefLink = targetElement.querySelector('a[data-footnote-backref]'); + if (backrefLink) { + focusTarget = backrefLink as HTMLElement; + } + } + } else { + // Default behavior: focus on any backref link + const backrefLink = targetElement.querySelector('a[data-footnote-backref]'); + if (backrefLink) { + focusTarget = backrefLink as HTMLElement; + } } } - focusTarget.focus({ preventScroll: true }); + focusTarget.focus(); let elementToHighlight = targetElement; diff --git a/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/UserMessage.tsx b/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/UserMessage.tsx index 9e83c055a..d9aa074ee 100644 --- a/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/UserMessage.tsx +++ b/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/UserMessage.tsx @@ -270,13 +270,32 @@ You can also reference the same footnote multiple times[^1]. // If we found a footnote definition container, look for the backref link inside it if (targetElement.id?.startsWith('user-content-fn-')) { - const backrefLink = targetElement.querySelector('a[data-footnote-backref]'); - if (backrefLink) { - focusTarget = backrefLink as HTMLElement; + // Check if we came from a specific footnote reference by looking at the clicked element + const clickedElement = event.target as HTMLElement; + const clickedAnchor = clickedElement.closest('a'); + + // If we clicked from a footnote reference, find the backref that points back to that specific reference + if (clickedAnchor?.id && clickedAnchor.id.startsWith('user-content-fnref-')) { + const specificBackref = targetElement.querySelector(`a[href="#${clickedAnchor.id}"]`); + if (specificBackref) { + focusTarget = specificBackref as HTMLElement; + } else { + // Fallback to any backref link + const backrefLink = targetElement.querySelector('a[data-footnote-backref]'); + if (backrefLink) { + focusTarget = backrefLink as HTMLElement; + } + } + } else { + // Default behavior: focus on any backref link + const backrefLink = targetElement.querySelector('a[data-footnote-backref]'); + if (backrefLink) { + focusTarget = backrefLink as HTMLElement; + } } } - focusTarget.focus({ preventScroll: true }); + focusTarget.focus(); // For all footnote navigation, find the nearest span with class "pf-chatbot__message-text" // to ensure we highlight the appropriate container From 7f5a8d8e7703f9ce85dce8a2197f74492010ffc8 Mon Sep 17 00:00:00 2001 From: Rebecca Alpert Date: Wed, 20 Aug 2025 15:31:14 -0400 Subject: [PATCH 4/8] Address focus, labeling, customization, and heading --- .../chatbot/examples/Messages/BotMessage.tsx | 45 +++++--------- .../chatbot/examples/Messages/UserMessage.tsx | 62 +++++-------------- .../Message/ListMessage/ListItemMessage.tsx | 4 +- .../src/Message/ListMessage/ListMessage.scss | 8 ++- packages/module/src/Message/Message.tsx | 39 +++++++++++- .../src/Message/TextMessage/TextMessage.scss | 5 ++ 6 files changed, 81 insertions(+), 82 deletions(-) diff --git a/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/BotMessage.tsx b/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/BotMessage.tsx index 76670dff8..55773257e 100644 --- a/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/BotMessage.tsx +++ b/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/BotMessage.tsx @@ -238,33 +238,14 @@ You can also reference the same footnote multiple times[^1]. targetElement = document.querySelector(`[id="${targetId}"]`); if (targetElement) { - // For footnote definitions, try to focus on the backref link inside let focusTarget = targetElement; - // If we found a footnote definition container, look for the backref link inside it - if (targetElement.id?.startsWith('user-content-fn-')) { - // Check if we came from a specific footnote reference by looking at the clicked element - const clickedElement = event.target as HTMLElement; - const clickedAnchor = clickedElement.closest('a'); - - // If we clicked from a footnote reference, find the backref that points back to that specific reference - if (clickedAnchor?.id && clickedAnchor.id.startsWith('user-content-fnref-')) { - const specificBackref = targetElement.querySelector(`a[href="#${clickedAnchor.id}"]`); - if (specificBackref) { - focusTarget = specificBackref as HTMLElement; - } else { - // Fallback to any backref link - const backrefLink = targetElement.querySelector('a[data-footnote-backref]'); - if (backrefLink) { - focusTarget = backrefLink as HTMLElement; - } - } - } else { - // Default behavior: focus on any backref link - const backrefLink = targetElement.querySelector('a[data-footnote-backref]'); - if (backrefLink) { - focusTarget = backrefLink as HTMLElement; - } + // If we found a footnote definition container, focus on the parent li element + if (targetElement.id?.startsWith('bot-message-fn-')) { + // Find the parent li element that contains the footnote + const parentLi = targetElement.closest('li'); + if (parentLi) { + focusTarget = parentLi as HTMLElement; } } @@ -275,22 +256,20 @@ You can also reference the same footnote multiple times[^1]. // If this is a backref link (going back to footnote reference), // we want to highlight more of the ref line and not just the link itself // since the target is so small - if (targetElement.id?.startsWith('user-content-fnref-')) { + if (targetElement.id?.startsWith('bot-message-fnref-')) { const refLink = targetElement; - // Walk up the DOM to find a paragraph or other meaningful container - // This may just be a "sup" element, so we want to find the parent + // Walk up the DOM to find a meaningful container let parent = refLink.parentElement; while (parent && parent.tagName.toLowerCase() !== 'p' && parent !== document.body) { parent = parent.parentElement; } - // Use the paragraph if found, otherwise use the immediate parent or target as a fallback + // Use if found, otherwise use the immediate parent or target as a fallback elementToHighlight = parent || refLink.parentElement || targetElement; } - // Briefly highlight the target element since we're not scrolling to it in this example - // You could also use onClick to implement scrolling to the target element if you wanted + // Briefly highlight the target element for fun to show what you can do const originalBackground = elementToHighlight.style.backgroundColor; const originalTransition = elementToHighlight.style.transition; @@ -382,6 +361,10 @@ You can also reference the same footnote multiple times[^1]. // Because they are in the same message, this requires a custom plugin to parse the syntax tree additionalRehypePlugins={[rehypeCodeBlockToggle]} linkProps={{ onClick }} + // clobberPrefix controls the label ids + reactMarkdownProps={{ + remarkRehypeOptions: { footnoteLabel: 'Bot message footnotes', clobberPrefix: 'bot-message-' } + }} /> ); diff --git a/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/UserMessage.tsx b/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/UserMessage.tsx index d9aa074ee..b57533cd1 100644 --- a/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/UserMessage.tsx +++ b/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/UserMessage.tsx @@ -265,61 +265,25 @@ You can also reference the same footnote multiple times[^1]. targetElement = document.querySelector(`[id="${targetId}"]`); if (targetElement) { - // For footnote definitions, try to focus on the backref link inside let focusTarget = targetElement; - // If we found a footnote definition container, look for the backref link inside it - if (targetElement.id?.startsWith('user-content-fn-')) { - // Check if we came from a specific footnote reference by looking at the clicked element - const clickedElement = event.target as HTMLElement; - const clickedAnchor = clickedElement.closest('a'); - - // If we clicked from a footnote reference, find the backref that points back to that specific reference - if (clickedAnchor?.id && clickedAnchor.id.startsWith('user-content-fnref-')) { - const specificBackref = targetElement.querySelector(`a[href="#${clickedAnchor.id}"]`); - if (specificBackref) { - focusTarget = specificBackref as HTMLElement; - } else { - // Fallback to any backref link - const backrefLink = targetElement.querySelector('a[data-footnote-backref]'); - if (backrefLink) { - focusTarget = backrefLink as HTMLElement; - } - } - } else { - // Default behavior: focus on any backref link - const backrefLink = targetElement.querySelector('a[data-footnote-backref]'); - if (backrefLink) { - focusTarget = backrefLink as HTMLElement; - } + // If we found a footnote definition container, focus on the parent li element + if (targetElement.id?.startsWith('user-message-fn-')) { + // Find the parent li element that contains the footnote + const parentLi = targetElement.closest('li'); + if (parentLi) { + focusTarget = parentLi as HTMLElement; } } focusTarget.focus(); - // For all footnote navigation, find the nearest span with class "pf-chatbot__message-text" - // to ensure we highlight the appropriate container let elementToHighlight = targetElement; - const searchStartElement = targetElement; - let elementToHighlightContainer: HTMLElement | null = null; - // If navigating to a footnote definition, look for the footnotes container - if (targetElement.id?.startsWith('user-content-fn-')) { - let parent = searchStartElement.parentElement; - while ( - parent && - !( - parent.tagName.toLowerCase() === 'div' && parent.classList.contains('pf-chatbot__message-ordered-list') - ) && - parent !== document.body - ) { - parent = parent.parentElement; - } - elementToHighlightContainer = parent; - } else { - // For footnote references, look for the message text span + // For footnote references, look for an appropriate container + if (!targetElement.id?.startsWith('user-message-fn-')) { let parent = searchStartElement.parentElement; while ( parent && @@ -334,8 +298,7 @@ You can also reference the same footnote multiple times[^1]. // Use the found container if available, otherwise fall back to the target element elementToHighlight = elementToHighlightContainer || targetElement; - // Briefly highlight the target element since we're not scrolling to it in this example - // You could also use onClick to implement scrolling to the target element if you wanted + // Briefly highlight the target element for fun to show what you can do const originalBackground = elementToHighlight.style.backgroundColor; const originalTransition = elementToHighlight.style.transition; @@ -424,6 +387,13 @@ You can also reference the same footnote multiple times[^1]. // Because they are in the same message, this requires a custom plugin to parse the syntax tree additionalRehypePlugins={[rehypeCodeBlockToggle]} linkProps={{ onClick }} + // clobberPrefix controls the label ids + reactMarkdownProps={{ + remarkRehypeOptions: { + footnoteLabel: 'User message footnotes', + clobberPrefix: 'user-message-' + } + }} /> ); diff --git a/packages/module/src/Message/ListMessage/ListItemMessage.tsx b/packages/module/src/Message/ListMessage/ListItemMessage.tsx index b345d6b16..2762ba18f 100644 --- a/packages/module/src/Message/ListMessage/ListItemMessage.tsx +++ b/packages/module/src/Message/ListMessage/ListItemMessage.tsx @@ -6,7 +6,9 @@ import { ExtraProps } from 'react-markdown'; import { ListItem } from '@patternfly/react-core'; const ListItemMessage = ({ children, ...props }: JSX.IntrinsicElements['li'] & ExtraProps) => ( - {children} + + {children} + ); export default ListItemMessage; diff --git a/packages/module/src/Message/ListMessage/ListMessage.scss b/packages/module/src/Message/ListMessage/ListMessage.scss index 8b6e7bc44..0dfb7fcd5 100644 --- a/packages/module/src/Message/ListMessage/ListMessage.scss +++ b/packages/module/src/Message/ListMessage/ListMessage.scss @@ -29,9 +29,11 @@ } // targets footnotes specifically and prevents misalignment problems - li[id*='user-content-fn-'] > span { - display: inline-flex; - flex-direction: column; + .footnotes { + li > span { + display: inline-flex; + flex-direction: column; + } } li a { diff --git a/packages/module/src/Message/Message.tsx b/packages/module/src/Message/Message.tsx index 28e1b460f..3cde8fd26 100644 --- a/packages/module/src/Message/Message.tsx +++ b/packages/module/src/Message/Message.tsx @@ -52,6 +52,7 @@ import { rehypeMoveImagesOutOfParagraphs } from './Plugins/rehypeMoveImagesOutOf import ToolResponse, { ToolResponseProps } from '../ToolResponse'; import DeepThinking, { DeepThinkingProps } from '../DeepThinking'; import SuperscriptMessage from './SuperscriptMessage/SuperscriptMessage'; +import { ElementContent } from 'rehype-external-links/lib'; export interface MessageAttachment { /** Name of file attached to the message */ @@ -198,6 +199,8 @@ export interface MessageProps extends Omit, 'role'> { toolResponse?: ToolResponseProps; /** Props for deep thinking card */ deepThinking?: DeepThinkingProps; + /** Allows passing additional props down to remark-gfm. See https://github.com/remarkjs/remark-gfm?tab=readme-ov-file#options for options */ + remarkGfmProps?: Options; } export const MessageBase: FunctionComponent = ({ @@ -242,6 +245,7 @@ export const MessageBase: FunctionComponent = ({ reactMarkdownProps, toolResponse, deepThinking, + remarkGfmProps, ...props }: MessageProps) => { const [messageText, setMessageText] = useState(content); @@ -268,6 +272,28 @@ export const MessageBase: FunctionComponent = ({ const date = new Date(); const dateString = timestamp ?? `${date.toLocaleDateString()} ${date.toLocaleTimeString()}`; + const defaultFootnoteBackContent = (referenceIndex: number, rereferenceIndex: number): ElementContent[] => { + const result: ElementContent[] = [{ type: 'text', value: '↩' }]; + + if (rereferenceIndex > 1) { + result.push({ + type: 'element', + tagName: 'sup', + properties: {}, + children: [{ type: 'text', value: `${String(referenceIndex + 1)}-${String(rereferenceIndex)}` }] + }); + } else { + result.push({ + type: 'element', + tagName: 'sup', + properties: {}, + children: [{ type: 'text', value: String(referenceIndex + 1) }] + }); + } + + return result; + }; + const handleMarkdown = () => { if (isMarkdownDisabled) { return ( @@ -279,6 +305,11 @@ export const MessageBase: FunctionComponent = ({ return ( { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { node, ...rest } = props; + return
; + }, p: (props) => { // eslint-disable-next-line @typescript-eslint/no-unused-vars const { node, ...rest } = props; @@ -395,9 +426,15 @@ export const MessageBase: FunctionComponent = ({ return ; } }} - remarkPlugins={[remarkGfm, ...additionalRemarkPlugins]} + remarkPlugins={[[remarkGfm, { ...remarkGfmProps }], ...additionalRemarkPlugins]} rehypePlugins={rehypePlugins} {...reactMarkdownProps} + remarkRehypeOptions={{ + // removes sr-only class from footnote labels applied by default + footnoteLabelProperties: { className: [''] }, + footnoteBackContent: defaultFootnoteBackContent, + ...reactMarkdownProps?.remarkRehypeOptions + }} > {messageText} diff --git a/packages/module/src/Message/TextMessage/TextMessage.scss b/packages/module/src/Message/TextMessage/TextMessage.scss index 6d14853c2..f390d5f54 100644 --- a/packages/module/src/Message/TextMessage/TextMessage.scss +++ b/packages/module/src/Message/TextMessage/TextMessage.scss @@ -88,6 +88,11 @@ li[id*='user-content-fn-']:has(> span > span > .pf-chatbot__message-text + .pf-c color: var(--pf-t--global--text--color--on-brand--default); } } + + .pf-chatbot__message-text > .pf-chatbot__message-text { + background-color: initial; + padding: initial; + } } // ============================================================================ From e1236b86a1d79df15bb2f1b01405fd9ff8ea2ed2 Mon Sep 17 00:00:00 2001 From: Rebecca Alpert Date: Tue, 26 Aug 2025 11:20:13 -0400 Subject: [PATCH 5/8] Remove dupes --- packages/module/src/Message/Message.tsx | 3 +- packages/module/src/Message/Plugins/index.ts | 1 + .../src/Message/Plugins/rehypeFootnotes.ts | 33 +++++++++++++++++++ 3 files changed, 36 insertions(+), 1 deletion(-) create mode 100644 packages/module/src/Message/Plugins/rehypeFootnotes.ts diff --git a/packages/module/src/Message/Message.tsx b/packages/module/src/Message/Message.tsx index 3cde8fd26..26c31587c 100644 --- a/packages/module/src/Message/Message.tsx +++ b/packages/module/src/Message/Message.tsx @@ -53,6 +53,7 @@ import ToolResponse, { ToolResponseProps } from '../ToolResponse'; import DeepThinking, { DeepThinkingProps } from '../DeepThinking'; import SuperscriptMessage from './SuperscriptMessage/SuperscriptMessage'; import { ElementContent } from 'rehype-external-links/lib'; +import { rehypeFootnotes } from './Plugins/rehypeFootnotes'; export interface MessageAttachment { /** Name of file attached to the message */ @@ -255,7 +256,7 @@ export const MessageBase: FunctionComponent = ({ }, [content]); const { beforeMainContent, afterMainContent, endContent } = extraContent || {}; - let rehypePlugins: PluggableList = [rehypeUnwrapImages, rehypeMoveImagesOutOfParagraphs]; + let rehypePlugins: PluggableList = [rehypeUnwrapImages, rehypeMoveImagesOutOfParagraphs, rehypeFootnotes]; if (openLinkInNewTab) { rehypePlugins = rehypePlugins.concat([[rehypeExternalLinks, { target: '_blank' }, rehypeSanitize]]); } diff --git a/packages/module/src/Message/Plugins/index.ts b/packages/module/src/Message/Plugins/index.ts index 7e155d69f..a544c10f2 100644 --- a/packages/module/src/Message/Plugins/index.ts +++ b/packages/module/src/Message/Plugins/index.ts @@ -1 +1,2 @@ export { rehypeCodeBlockToggle } from './rehypeCodeBlockToggle'; +export { rehypeFootnotes } from './rehypeFootnotes'; diff --git a/packages/module/src/Message/Plugins/rehypeFootnotes.ts b/packages/module/src/Message/Plugins/rehypeFootnotes.ts new file mode 100644 index 000000000..835dfa005 --- /dev/null +++ b/packages/module/src/Message/Plugins/rehypeFootnotes.ts @@ -0,0 +1,33 @@ +import { visit } from 'unist-util-visit'; + +export const rehypeFootnotes = () => (tree) => { + const visitedFootnotes = new Set(); + + visit(tree, 'element', (node, index, parent) => { + // Each footnote backref generates an li with N footnote backrefs + if (node.tagName === 'li') { + if (!visitedFootnotes.has(node.properties.id)) { + visitedFootnotes.add(node.properties.id); + } + } + // The class name is added by remark-gfm and is pretty standard + if ( + node.tagName === 'a' && + node.properties.className && + node.properties.className.includes('data-footnote-backref') + ) { + // Get the ID of the footnote from the href + const backrefId = node.properties.href.replace('#', '').replace('fnref', 'fn'); + + // Check if we have already seen a back-reference for this footnote. + // If it is a repeat, it will not be in the list exactly + // Footnote id will be bot-message-fn-1, and there will be backrefs + // bot-message-fnref-1 and bot-message-fnref-1-2, etc. + if (!visitedFootnotes.has(backrefId)) { + if (parent && parent.children) { + parent.children.splice(index, 1); + } + } + } + }); +}; From 4534f3d6f0be2abb48820ffaee8b94b03ed522ab Mon Sep 17 00:00:00 2001 From: Rebecca Alpert Date: Thu, 28 Aug 2025 16:14:16 -0400 Subject: [PATCH 6/8] Adjust styles --- .../chatbot/examples/Messages/BotMessage.tsx | 2 - .../chatbot/examples/Messages/UserMessage.tsx | 2 - .../CodeBlockMessage/CodeBlockMessage.scss | 3 +- packages/module/src/Message/Message.scss | 39 +++++++++++++++++++ packages/module/src/Message/Message.tsx | 27 +------------ packages/module/src/Message/Plugins/index.ts | 1 - .../src/Message/Plugins/rehypeFootnotes.ts | 33 ---------------- .../src/Message/TextMessage/TextMessage.scss | 12 +++--- 8 files changed, 49 insertions(+), 70 deletions(-) delete mode 100644 packages/module/src/Message/Plugins/rehypeFootnotes.ts diff --git a/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/BotMessage.tsx b/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/BotMessage.tsx index 55773257e..ce79badce 100644 --- a/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/BotMessage.tsx +++ b/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/BotMessage.tsx @@ -162,8 +162,6 @@ _Italic text, formatted with single underscores_ const footnote = `This is some text that has a short footnote[^1] and this is text with a longer footnote.[^bignote] -You can also reference the same footnote multiple times[^1]. - [^1]: This is a short footnote. To return the highlight to the original message, click the arrow. [^bignote]: This is a long footnote with multiple paragraphs and formatting. diff --git a/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/UserMessage.tsx b/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/UserMessage.tsx index b57533cd1..36e571380 100644 --- a/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/UserMessage.tsx +++ b/packages/module/patternfly-docs/content/extensions/chatbot/examples/Messages/UserMessage.tsx @@ -184,8 +184,6 @@ _Italic text, formatted with single underscores_ const footnote = `This is some text that has a short footnote[^1] and this is text with a longer footnote.[^bignote] -You can also reference the same footnote multiple times[^1]. - [^1]: This is a short footnote. To return the highlight to the original message, click the arrow. [^bignote]: This is a long footnote with multiple paragraphs and formatting. diff --git a/packages/module/src/Message/CodeBlockMessage/CodeBlockMessage.scss b/packages/module/src/Message/CodeBlockMessage/CodeBlockMessage.scss index 50ece9bde..8d92fefb0 100644 --- a/packages/module/src/Message/CodeBlockMessage/CodeBlockMessage.scss +++ b/packages/module/src/Message/CodeBlockMessage/CodeBlockMessage.scss @@ -77,8 +77,9 @@ } .pf-chatbot__message-inline-code { + --pf-chatbot-message-text-inline-code-font-size: var(--pf-t--global--font--size--body--default); background-color: var(--pf-t--global--background--color--tertiary--default); - font-size: var(--pf-t--global--font--size--body--default); + font-size: var(--pf-chatbot-message-text-inline-code-font-size); } .pf-chatbot__message-code-toggle { diff --git a/packages/module/src/Message/Message.scss b/packages/module/src/Message/Message.scss index 768f6071d..eab2b1b72 100644 --- a/packages/module/src/Message/Message.scss +++ b/packages/module/src/Message/Message.scss @@ -89,6 +89,45 @@ display: grid; gap: var(--pf-t--global--spacer--sm); } + + // targets footnotes specifically + .footnotes { + background-color: var(--pf-t--global--background--color--tertiary--default); + padding: var(--pf-t--global--spacer--md); + --pf-chatbot-message-text-font-size: var(--pf-t--global--font--size--xs); + --pf-chatbot-message-text-inline-code-font-size: var(--pf-t--global--font--size--xs); + + .pf-chatbot__message-text h1, + h2, + h3, + h4, + h5, + h6 { + --pf-v6-c-content--h1--FontSize: var(--pf-t--global--font--size--md); + --pf-v6-c-content--h2--FontSize: var(--pf-t--global--font--size--md); + --pf-v6-c-content--h3--FontSize: var(--pf-t--global--font--size--md); + --pf-v6-c-content--h4--FontSize: var(--pf-t--global--font--size--md); + --pf-v6-c-content--h5--FontSize: var(--pf-t--global--font--size--md); + --pf-v6-c-content--h6--FontSize: var(--pf-t--global--font--size--md); + } + .pf-chatbot__message-text .pf-v6-c-content, + .pf-chatbot__message-text .pf-v6-c-content--small, + .pf-chatbot__message-text .pf-v6-c-content--blockquote, + .pf-chatbot__message-text p, + .pf-chatbot__message-text a { + --pf-v6-c-content--FontSize: var(--pf-t--global--font--size--xs); + } + .pf-chatbot__message-inline-code, + .pf-chatbot__message-text .pf-v6-c-button.pf-m-link, + .pf-chatbot__message-ordered-list .pf-v6-c-list, + .pf-chatbot__message-ordered-list ul, + .pf-chatbot__message-ordered-list li, + .pf-chatbot__message-unordered-list .pf-v6-c-list, + .pf-chatbot__message-unordered-list ul, + .pf-chatbot__message-unordered-list li { + font-size: var(--pf-t--global--font--size--xs); + } + } } // Attachments diff --git a/packages/module/src/Message/Message.tsx b/packages/module/src/Message/Message.tsx index 26c31587c..7d2dcc805 100644 --- a/packages/module/src/Message/Message.tsx +++ b/packages/module/src/Message/Message.tsx @@ -52,8 +52,6 @@ import { rehypeMoveImagesOutOfParagraphs } from './Plugins/rehypeMoveImagesOutOf import ToolResponse, { ToolResponseProps } from '../ToolResponse'; import DeepThinking, { DeepThinkingProps } from '../DeepThinking'; import SuperscriptMessage from './SuperscriptMessage/SuperscriptMessage'; -import { ElementContent } from 'rehype-external-links/lib'; -import { rehypeFootnotes } from './Plugins/rehypeFootnotes'; export interface MessageAttachment { /** Name of file attached to the message */ @@ -256,7 +254,7 @@ export const MessageBase: FunctionComponent = ({ }, [content]); const { beforeMainContent, afterMainContent, endContent } = extraContent || {}; - let rehypePlugins: PluggableList = [rehypeUnwrapImages, rehypeMoveImagesOutOfParagraphs, rehypeFootnotes]; + let rehypePlugins: PluggableList = [rehypeUnwrapImages, rehypeMoveImagesOutOfParagraphs]; if (openLinkInNewTab) { rehypePlugins = rehypePlugins.concat([[rehypeExternalLinks, { target: '_blank' }, rehypeSanitize]]); } @@ -273,28 +271,6 @@ export const MessageBase: FunctionComponent = ({ const date = new Date(); const dateString = timestamp ?? `${date.toLocaleDateString()} ${date.toLocaleTimeString()}`; - const defaultFootnoteBackContent = (referenceIndex: number, rereferenceIndex: number): ElementContent[] => { - const result: ElementContent[] = [{ type: 'text', value: '↩' }]; - - if (rereferenceIndex > 1) { - result.push({ - type: 'element', - tagName: 'sup', - properties: {}, - children: [{ type: 'text', value: `${String(referenceIndex + 1)}-${String(rereferenceIndex)}` }] - }); - } else { - result.push({ - type: 'element', - tagName: 'sup', - properties: {}, - children: [{ type: 'text', value: String(referenceIndex + 1) }] - }); - } - - return result; - }; - const handleMarkdown = () => { if (isMarkdownDisabled) { return ( @@ -433,7 +409,6 @@ export const MessageBase: FunctionComponent = ({ remarkRehypeOptions={{ // removes sr-only class from footnote labels applied by default footnoteLabelProperties: { className: [''] }, - footnoteBackContent: defaultFootnoteBackContent, ...reactMarkdownProps?.remarkRehypeOptions }} > diff --git a/packages/module/src/Message/Plugins/index.ts b/packages/module/src/Message/Plugins/index.ts index a544c10f2..7e155d69f 100644 --- a/packages/module/src/Message/Plugins/index.ts +++ b/packages/module/src/Message/Plugins/index.ts @@ -1,2 +1 @@ export { rehypeCodeBlockToggle } from './rehypeCodeBlockToggle'; -export { rehypeFootnotes } from './rehypeFootnotes'; diff --git a/packages/module/src/Message/Plugins/rehypeFootnotes.ts b/packages/module/src/Message/Plugins/rehypeFootnotes.ts deleted file mode 100644 index 835dfa005..000000000 --- a/packages/module/src/Message/Plugins/rehypeFootnotes.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { visit } from 'unist-util-visit'; - -export const rehypeFootnotes = () => (tree) => { - const visitedFootnotes = new Set(); - - visit(tree, 'element', (node, index, parent) => { - // Each footnote backref generates an li with N footnote backrefs - if (node.tagName === 'li') { - if (!visitedFootnotes.has(node.properties.id)) { - visitedFootnotes.add(node.properties.id); - } - } - // The class name is added by remark-gfm and is pretty standard - if ( - node.tagName === 'a' && - node.properties.className && - node.properties.className.includes('data-footnote-backref') - ) { - // Get the ID of the footnote from the href - const backrefId = node.properties.href.replace('#', '').replace('fnref', 'fn'); - - // Check if we have already seen a back-reference for this footnote. - // If it is a repeat, it will not be in the list exactly - // Footnote id will be bot-message-fn-1, and there will be backrefs - // bot-message-fnref-1 and bot-message-fnref-1-2, etc. - if (!visitedFootnotes.has(backrefId)) { - if (parent && parent.children) { - parent.children.splice(index, 1); - } - } - } - }); -}; diff --git a/packages/module/src/Message/TextMessage/TextMessage.scss b/packages/module/src/Message/TextMessage/TextMessage.scss index f390d5f54..679e6e148 100644 --- a/packages/module/src/Message/TextMessage/TextMessage.scss +++ b/packages/module/src/Message/TextMessage/TextMessage.scss @@ -17,9 +17,10 @@ width: fit-content; padding: var(--pf-t--global--spacer--sm) 0 var(--pf-t--global--spacer--sm) 0; border-radius: var(--pf-t--global--border--radius--small); + --pf-chatbot-message-text-font-size: var(--pf-t--global--font--size--md); .pf-v6-c-button.pf-m-link { - font-size: var(--pf-t--global--font--size--md); + font-size: var(--pf-chatbot-message-text-font-size); } .pf-v6-c-content, @@ -27,12 +28,12 @@ .pf-v6-c-content--blockquote, p, a { - --pf-v6-c-content--FontSize: var(--pf-t--global--font--size--md); + --pf-v6-c-content--FontSize: var(--pf-chatbot-message-text-font-size); } code { background-color: var(--pf-t--global--background--color--tertiary--default); - font-size: var(--pf-t--global--font--size--body--default); + font-size: var(--pf-chatbot-message-text-inline-code-font-size); } // Hide message text that contains sr-only content @@ -101,8 +102,9 @@ li[id*='user-content-fn-']:has(> span > span > .pf-chatbot__message-text + .pf-c .pf-chatbot.pf-m-compact { // Need to inline shorter text .pf-chatbot__message-text { + --pf-chatbot-message-text-font-size: var(--pf-t--global--font--size--sm); .pf-v6-c-button.pf-m-link { - font-size: var(--pf-t--global--font--size--sm); + font-size: var(--pf-chatbot-message-text-font-size); } .pf-v6-c-content, @@ -110,7 +112,7 @@ li[id*='user-content-fn-']:has(> span > span > .pf-chatbot__message-text + .pf-c .pf-v6-c-content--blockquote, p, a { - --pf-v6-c-content--FontSize: var(--pf-t--global--font--size--sm); + --pf-v6-c-content--FontSize: var(--pf-chatbot-message-text-font-size); } .pf-v6-c-content--blockquote { From 73e51a2c7ee3737480e034f1b3ad5584cb95dadd Mon Sep 17 00:00:00 2001 From: Rebecca Alpert Date: Thu, 4 Sep 2025 13:50:22 -0400 Subject: [PATCH 7/8] Adjust padding --- packages/module/src/Message/Message.scss | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/module/src/Message/Message.scss b/packages/module/src/Message/Message.scss index eab2b1b72..2481d7e9f 100644 --- a/packages/module/src/Message/Message.scss +++ b/packages/module/src/Message/Message.scss @@ -91,9 +91,9 @@ } // targets footnotes specifically - .footnotes { - background-color: var(--pf-t--global--background--color--tertiary--default); - padding: var(--pf-t--global--spacer--md); + .footnotes, + .pf-chatbot__message-text.footnotes { + padding: var(--pf-t--global--spacer--sm) var(--pf-t--global--spacer--sm) 0 var(--pf-t--global--spacer--sm); --pf-chatbot-message-text-font-size: var(--pf-t--global--font--size--xs); --pf-chatbot-message-text-inline-code-font-size: var(--pf-t--global--font--size--xs); @@ -128,6 +128,10 @@ font-size: var(--pf-t--global--font--size--xs); } } + + .footnotes { + background-color: var(--pf-t--global--background--color--tertiary--default); + } } // Attachments From ae6eabfb7298947074ca8b8225547fc201183648 Mon Sep 17 00:00:00 2001 From: Rebecca Alpert Date: Fri, 5 Sep 2025 09:23:02 -0400 Subject: [PATCH 8/8] Fix linter issue post-rebase