From 2c100d8f99aba6317d6dd30322c46de144ba7932 Mon Sep 17 00:00:00 2001 From: Kristian Antrobus Date: Tue, 17 Dec 2024 14:39:23 -0600 Subject: [PATCH 01/13] feat(ai-chat-log): wip typewriter animations --- .../ai-chat-log/src/AIChatLogContext.tsx | 8 ++ .../src/AIChatMessageBodyTypeWriter.tsx | 72 +++++++++++++++++ .../components/ai-chat-log/src/index.tsx | 3 + .../components/ai-chat-log/src/utils.tsx | 77 +++++++++++++++++++ .../ai-chat-log/stories/parts.stories.tsx | 66 ++++++++++++++++ 5 files changed, 226 insertions(+) create mode 100644 packages/paste-core/components/ai-chat-log/src/AIChatLogContext.tsx create mode 100644 packages/paste-core/components/ai-chat-log/src/AIChatMessageBodyTypeWriter.tsx create mode 100644 packages/paste-core/components/ai-chat-log/src/utils.tsx diff --git a/packages/paste-core/components/ai-chat-log/src/AIChatLogContext.tsx b/packages/paste-core/components/ai-chat-log/src/AIChatLogContext.tsx new file mode 100644 index 0000000000..0563c6e1c0 --- /dev/null +++ b/packages/paste-core/components/ai-chat-log/src/AIChatLogContext.tsx @@ -0,0 +1,8 @@ +import * as React from "react"; + + +export interface AIChatLogContextProps { + isAnimating: boolean; + setIsAnimating: (animating: boolean) => void; +} +export const AIChatLogContext = React.createContext({} as any); diff --git a/packages/paste-core/components/ai-chat-log/src/AIChatMessageBodyTypeWriter.tsx b/packages/paste-core/components/ai-chat-log/src/AIChatMessageBodyTypeWriter.tsx new file mode 100644 index 0000000000..e64cf7625a --- /dev/null +++ b/packages/paste-core/components/ai-chat-log/src/AIChatMessageBodyTypeWriter.tsx @@ -0,0 +1,72 @@ +import { Box, safelySpreadBoxProps } from "@twilio-paste/box"; +import type { BoxElementProps } from "@twilio-paste/box"; +import type { ThemeShape } from "@twilio-paste/theme"; +import type { HTMLPasteProps } from "@twilio-paste/types"; +import * as React from "react"; +import { useAnimatedText } from "./utils"; + +const Variants = { + default: { + fontSize: "fontSize30" as ThemeShape["fontSizes"], + lineHeight: "lineHeight30" as ThemeShape["lineHeights"], + }, + fullScreen: { + fontSize: "fontSize40" as ThemeShape["fontSizes"], + lineHeight: "lineHeight40" as ThemeShape["lineHeights"], + }, +}; + +export interface AIChatMessageBodyTypeWriterProps extends HTMLPasteProps<"div"> { + children?: React.ReactNode; + /** + * Overrides the default element name to apply unique styles with the Customization Provider + * + * @default "AI_CHAT_MESSAGE_BODY_TYPE_WRITER" + * @type {BoxProps["element"]} + * @memberof AIChatMessageBodyTypeWriterProps + */ + element?: BoxElementProps["element"]; + /** + * Override the font size for full screen experiences. + * + * @default "default" + * @type {"default" | "fullScreen"} + * @memberof AIChatMessageBodyTypeWriterProps + */ + variant?: "default" | "fullScreen"; + /** + * Whether the text should be animated with type writer effect + * + * @default true + * @type {boolean} + * @memberof AIChatMessageBodyTypeWriterProps + */ + animated?: boolean; +} + +export const AIChatMessageBodyTypeWriter = React.forwardRef( + ({ children, variant = "default", element = "AI_CHAT_MESSAGE_BODY_TYPE_WRITER", animated = true, onAnimationEnd, onAnimationStart, ...props }, ref) => { + const animatedChildren = useAnimatedText(children); + + return ( + + {animated + ? animatedChildren + : children} + + ); + }, +); + +AIChatMessageBodyTypeWriter.displayName = "AIChatMessageBodyTypeWriter"; diff --git a/packages/paste-core/components/ai-chat-log/src/index.tsx b/packages/paste-core/components/ai-chat-log/src/index.tsx index cf8f5b65eb..d0bec81379 100644 --- a/packages/paste-core/components/ai-chat-log/src/index.tsx +++ b/packages/paste-core/components/ai-chat-log/src/index.tsx @@ -10,6 +10,9 @@ export { AIChatMessageActionCard } from "./AIChatMessageActionCard"; export type { AIChatMessageActionCardProps } from "./AIChatMessageActionCard"; export { AIChatMessageLoading } from "./AIChatMessageLoading"; export type { AIChatMessageLoadingProps } from "./AIChatMessageLoading"; +export { AIChatMessageBodyTypeWriter } from "./AIChatMessageBodyTypeWriter"; +export type { AIChatMessageBodyTypeWriterProps } from "./AIChatMessageBodyTypeWriter"; +export { AIChatLogContext } from "./AIChatLogContext"; export { AIChatLog } from "./AIChatLog"; export type { AIChatLogProps } from "./AIChatLog"; diff --git a/packages/paste-core/components/ai-chat-log/src/utils.tsx b/packages/paste-core/components/ai-chat-log/src/utils.tsx new file mode 100644 index 0000000000..4a9b944609 --- /dev/null +++ b/packages/paste-core/components/ai-chat-log/src/utils.tsx @@ -0,0 +1,77 @@ +import React, { useState, useEffect } from "react"; +import { AIChatLogContext } from "./AIChatLogContext"; + +// Hook to animate text content of React elements +export const useAnimatedText = (children: React.ReactNode, speed: number = 10): React.ReactNode => { + const {setIsAnimating, isAnimating} = React.useContext(AIChatLogContext); + const [animatedChildren, setAnimatedChildren] = useState(); + const [textIndex, setTextIndex] = useState(0); + + // Effect to increment textIndex at a specified speed + useEffect(() => { + const interval = setInterval(() => { + setTextIndex((prevIndex) => prevIndex + 1); + }, speed); + + return () => clearInterval(interval); + }, [speed]); + + // Function to calculate the total length of text within nested elements + const calculateTotalTextLength = (children: React.ReactNode): number => { + let length = 0; + React.Children.forEach(children, (child) => { + if (typeof child === "string") { + length += child.length; + } else if (React.isValidElement(child)) { + length += calculateTotalTextLength(child.props.children); + } + }); + return length; + }; + + // Function to recursively clone children and apply text animation + const cloneChildren = (children: React.ReactNode, currentIndex: number): React.ReactNode => { + let currentTextIndex = currentIndex; + return React.Children.map(children, (child) => { + if (typeof child === "string") { + // Only include text nodes if their animation has started + if (currentTextIndex > 0) { + const visibleText = child.slice(0, currentTextIndex); + currentTextIndex -= child.length; + return {visibleText}; + } + return null; + } + + if (React.isValidElement(child)) { + const totalChildTextLength = calculateTotalTextLength(child.props.children); + // Only include elements if their text animation has started + if (currentTextIndex > 0) { + const clonedChild = React.cloneElement(child, {}, cloneChildren(child.props.children, currentTextIndex)); + currentTextIndex -= totalChildTextLength; + return clonedChild; + } + return null; + } + + return child; + }); + }; + + // Effect to update animated children based on the current text index + useEffect(() => { + const totaLength = calculateTotalTextLength(children); + if (textIndex <= totaLength) { + setAnimatedChildren(cloneChildren(children, textIndex)); + if(!isAnimating){ + setIsAnimating && setIsAnimating(true); + } + } else if(isAnimating){ + setIsAnimating && setIsAnimating(false); + } + }, [children, textIndex]); + + return animatedChildren +}; + +export default useAnimatedText; diff --git a/packages/paste-core/components/ai-chat-log/stories/parts.stories.tsx b/packages/paste-core/components/ai-chat-log/stories/parts.stories.tsx index 0949285fac..8ca635a52c 100644 --- a/packages/paste-core/components/ai-chat-log/stories/parts.stories.tsx +++ b/packages/paste-core/components/ai-chat-log/stories/parts.stories.tsx @@ -1,14 +1,19 @@ // eslint-disable-next-line eslint-comments/disable-enable-pair /* eslint-disable import/no-extraneous-dependencies */ +import { Anchor } from "@twilio-paste/anchor"; import { Box } from "@twilio-paste/box"; import { Button } from "@twilio-paste/button"; import { ButtonGroup } from "@twilio-paste/button-group"; +import { Disclosure, DisclosureContent, DisclosureHeading } from "@twilio-paste/disclosure"; +import { Heading } from "@twilio-paste/heading"; import { CopyIcon } from "@twilio-paste/icons/esm/CopyIcon"; import { RefreshIcon } from "@twilio-paste/icons/esm/RefreshIcon"; import { ThumbsDownIcon } from "@twilio-paste/icons/esm/ThumbsDownIcon"; import { ThumbsUpIcon } from "@twilio-paste/icons/esm/ThumbsUpIcon"; import { UserIcon } from "@twilio-paste/icons/esm/UserIcon"; import { InlineCode } from "@twilio-paste/inline-code"; +import { ListItem, UnorderedList } from "@twilio-paste/list"; +import { Paragraph } from "@twilio-paste/paragraph"; import * as React from "react"; import { @@ -18,6 +23,7 @@ import { AIChatMessageActionGroup, AIChatMessageAuthor, AIChatMessageBody, + AIChatMessageBodyTypeWriter, AIChatMessageLoading, } from "../src"; @@ -204,3 +210,63 @@ export const FullAIMessage = (): React.ReactNode => { ); }; + +export const MessageBodyTypeWriter = (): React.ReactNode => { + return ( + + + With enriched text + + + + + Lorem ipsum dolor, sit amet consectetur adipisicing elit. Deserunt + delectus fuga, necessitatibus eligendiiure adipisci facilis exercitationem officiis dolorem laborum, ex + fugiat quisquam itaque, earum sit nesciunt impedit repellat assumenda. new + text,{" "} + + 434324 + + + + Item 1 + Item 2 + Item 3 + + + + Between the World and Me by Ta-Nehisi Coates + + + But race is the child of racism, not the father. And the process of naming “the people” has never been a + matter of genealogy and physiognomy so much as one of hierarchy. Difference in hue and hair is old. But + the belief in the preeminence of hue and hair, the notion that these factors can correctly organize a + society and that they signify deeper attributes, which are indelible—this is the new idea at the heart of + these new people who have been brought up hopelessly, tragically, deceitfully, to believe that they are + white. + + + +
+ + + +
+ {/* + Without enriched text [fullscreen variant]: + + + + + Lorem ipsum dolor, sit amet consectetur adipisicing elit. Deserunt delectus fuga, necessitatibus eligendiiure + adipisci facilis exercitationem officiis dolorem laborum, ex fugiat quisquam itaque, earum sit + + + Item 1 + Item 2 + Item 3 + + */} +
+ ); +}; From 94a93153091cff0cbedbdc13eafcdaa7fd173b0a Mon Sep 17 00:00:00 2001 From: Kristian Antrobus Date: Tue, 17 Dec 2024 14:55:11 -0600 Subject: [PATCH 02/13] fix(ai-chat-log): remove nested span --- packages/paste-core/components/ai-chat-log/src/utils.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/paste-core/components/ai-chat-log/src/utils.tsx b/packages/paste-core/components/ai-chat-log/src/utils.tsx index 4a9b944609..053c4e9b84 100644 --- a/packages/paste-core/components/ai-chat-log/src/utils.tsx +++ b/packages/paste-core/components/ai-chat-log/src/utils.tsx @@ -38,7 +38,7 @@ export const useAnimatedText = (children: React.ReactNode, speed: number = 10): if (currentTextIndex > 0) { const visibleText = child.slice(0, currentTextIndex); currentTextIndex -= child.length; - return {visibleText}; + return visibleText; } return null; } From 32cc7028c2d75f9059ef2a9f4a9156e6ee99a3a6 Mon Sep 17 00:00:00 2001 From: Kristian Antrobus Date: Thu, 19 Dec 2024 10:43:30 -0600 Subject: [PATCH 03/13] feat(ai-chat-log): typewiter code --- .../ai-chat-log/src/AIChatLogContext.tsx | 8 -- .../ai-chat-log/src/AIChatMessageBody.tsx | 14 +- .../src/AIChatMessageBodyTypeWriter.tsx | 72 ---------- .../components/ai-chat-log/src/index.tsx | 1 - .../components/ai-chat-log/src/utils.tsx | 19 +-- .../ai-chat-log/stories/parts.stories.tsx | 132 ++++++++++++------ .../stories/useAIChatLogger.stories.tsx | 2 +- 7 files changed, 106 insertions(+), 142 deletions(-) delete mode 100644 packages/paste-core/components/ai-chat-log/src/AIChatLogContext.tsx delete mode 100644 packages/paste-core/components/ai-chat-log/src/AIChatMessageBodyTypeWriter.tsx diff --git a/packages/paste-core/components/ai-chat-log/src/AIChatLogContext.tsx b/packages/paste-core/components/ai-chat-log/src/AIChatLogContext.tsx deleted file mode 100644 index 0563c6e1c0..0000000000 --- a/packages/paste-core/components/ai-chat-log/src/AIChatLogContext.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import * as React from "react"; - - -export interface AIChatLogContextProps { - isAnimating: boolean; - setIsAnimating: (animating: boolean) => void; -} -export const AIChatLogContext = React.createContext({} as any); diff --git a/packages/paste-core/components/ai-chat-log/src/AIChatMessageBody.tsx b/packages/paste-core/components/ai-chat-log/src/AIChatMessageBody.tsx index ef51fe17df..67a4015824 100644 --- a/packages/paste-core/components/ai-chat-log/src/AIChatMessageBody.tsx +++ b/packages/paste-core/components/ai-chat-log/src/AIChatMessageBody.tsx @@ -4,6 +4,7 @@ import type { HTMLPasteProps } from "@twilio-paste/types"; import * as React from "react"; import { AIMessageContext } from "./AIMessageContext"; +import useAnimatedText from "./utils"; const Sizes: Record = { default: { @@ -35,11 +36,20 @@ export interface AIChatMessageBodyProps extends HTMLPasteProps<"div"> { * @memberof AIChatMessageBodyProps */ size?: "default" | "fullScreen"; + /** + * Whether the text should be animated with type writer effect + * + * @default false + * @type {boolean} + * @memberof AIChatMessageBodyProps + */ + animated?: boolean; } export const AIChatMessageBody = React.forwardRef( - ({ children, size = "default", element = "AI_CHAT_MESSAGE_BODY", ...props }, ref) => { + ({ children, size = "default", element = "AI_CHAT_MESSAGE_BODY", animated = false, ...props }, ref) => { const { id } = React.useContext(AIMessageContext); + const childrenToRender = animated ? useAnimatedText(children) : children; return ( - {children} + {childrenToRender} ); }, diff --git a/packages/paste-core/components/ai-chat-log/src/AIChatMessageBodyTypeWriter.tsx b/packages/paste-core/components/ai-chat-log/src/AIChatMessageBodyTypeWriter.tsx deleted file mode 100644 index e64cf7625a..0000000000 --- a/packages/paste-core/components/ai-chat-log/src/AIChatMessageBodyTypeWriter.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import { Box, safelySpreadBoxProps } from "@twilio-paste/box"; -import type { BoxElementProps } from "@twilio-paste/box"; -import type { ThemeShape } from "@twilio-paste/theme"; -import type { HTMLPasteProps } from "@twilio-paste/types"; -import * as React from "react"; -import { useAnimatedText } from "./utils"; - -const Variants = { - default: { - fontSize: "fontSize30" as ThemeShape["fontSizes"], - lineHeight: "lineHeight30" as ThemeShape["lineHeights"], - }, - fullScreen: { - fontSize: "fontSize40" as ThemeShape["fontSizes"], - lineHeight: "lineHeight40" as ThemeShape["lineHeights"], - }, -}; - -export interface AIChatMessageBodyTypeWriterProps extends HTMLPasteProps<"div"> { - children?: React.ReactNode; - /** - * Overrides the default element name to apply unique styles with the Customization Provider - * - * @default "AI_CHAT_MESSAGE_BODY_TYPE_WRITER" - * @type {BoxProps["element"]} - * @memberof AIChatMessageBodyTypeWriterProps - */ - element?: BoxElementProps["element"]; - /** - * Override the font size for full screen experiences. - * - * @default "default" - * @type {"default" | "fullScreen"} - * @memberof AIChatMessageBodyTypeWriterProps - */ - variant?: "default" | "fullScreen"; - /** - * Whether the text should be animated with type writer effect - * - * @default true - * @type {boolean} - * @memberof AIChatMessageBodyTypeWriterProps - */ - animated?: boolean; -} - -export const AIChatMessageBodyTypeWriter = React.forwardRef( - ({ children, variant = "default", element = "AI_CHAT_MESSAGE_BODY_TYPE_WRITER", animated = true, onAnimationEnd, onAnimationStart, ...props }, ref) => { - const animatedChildren = useAnimatedText(children); - - return ( - - {animated - ? animatedChildren - : children} - - ); - }, -); - -AIChatMessageBodyTypeWriter.displayName = "AIChatMessageBodyTypeWriter"; diff --git a/packages/paste-core/components/ai-chat-log/src/index.tsx b/packages/paste-core/components/ai-chat-log/src/index.tsx index d0bec81379..a477cf5271 100644 --- a/packages/paste-core/components/ai-chat-log/src/index.tsx +++ b/packages/paste-core/components/ai-chat-log/src/index.tsx @@ -12,7 +12,6 @@ export { AIChatMessageLoading } from "./AIChatMessageLoading"; export type { AIChatMessageLoadingProps } from "./AIChatMessageLoading"; export { AIChatMessageBodyTypeWriter } from "./AIChatMessageBodyTypeWriter"; export type { AIChatMessageBodyTypeWriterProps } from "./AIChatMessageBodyTypeWriter"; -export { AIChatLogContext } from "./AIChatLogContext"; export { AIChatLog } from "./AIChatLog"; export type { AIChatLogProps } from "./AIChatLog"; diff --git a/packages/paste-core/components/ai-chat-log/src/utils.tsx b/packages/paste-core/components/ai-chat-log/src/utils.tsx index 053c4e9b84..e6b3adee4a 100644 --- a/packages/paste-core/components/ai-chat-log/src/utils.tsx +++ b/packages/paste-core/components/ai-chat-log/src/utils.tsx @@ -1,9 +1,7 @@ -import React, { useState, useEffect } from "react"; -import { AIChatLogContext } from "./AIChatLogContext"; +import React, { useEffect, useState } from "react"; // Hook to animate text content of React elements -export const useAnimatedText = (children: React.ReactNode, speed: number = 10): React.ReactNode => { - const {setIsAnimating, isAnimating} = React.useContext(AIChatLogContext); +export const useAnimatedText = (children: React.ReactNode, speed = 10): React.ReactNode => { const [animatedChildren, setAnimatedChildren] = useState(); const [textIndex, setTextIndex] = useState(0); @@ -41,15 +39,15 @@ export const useAnimatedText = (children: React.ReactNode, speed: number = 10): return visibleText; } return null; - } - - if (React.isValidElement(child)) { + } else if (React.isValidElement(child)) { const totalChildTextLength = calculateTotalTextLength(child.props.children); // Only include elements if their text animation has started if (currentTextIndex > 0) { const clonedChild = React.cloneElement(child, {}, cloneChildren(child.props.children, currentTextIndex)); currentTextIndex -= totalChildTextLength; return clonedChild; + } else if (currentTextIndex === 0 && totalChildTextLength === 0) { + return child; } return null; } @@ -63,15 +61,10 @@ export const useAnimatedText = (children: React.ReactNode, speed: number = 10): const totaLength = calculateTotalTextLength(children); if (textIndex <= totaLength) { setAnimatedChildren(cloneChildren(children, textIndex)); - if(!isAnimating){ - setIsAnimating && setIsAnimating(true); - } - } else if(isAnimating){ - setIsAnimating && setIsAnimating(false); } }, [children, textIndex]); - return animatedChildren + return animatedChildren; }; export default useAnimatedText; diff --git a/packages/paste-core/components/ai-chat-log/stories/parts.stories.tsx b/packages/paste-core/components/ai-chat-log/stories/parts.stories.tsx index 8ca635a52c..6d9465c9db 100644 --- a/packages/paste-core/components/ai-chat-log/stories/parts.stories.tsx +++ b/packages/paste-core/components/ai-chat-log/stories/parts.stories.tsx @@ -1,9 +1,12 @@ // eslint-disable-next-line eslint-comments/disable-enable-pair /* eslint-disable import/no-extraneous-dependencies */ import { Anchor } from "@twilio-paste/anchor"; +import { Blockquote, BlockquoteCitation, BlockquoteContent } from "@twilio-paste/blockquote/"; import { Box } from "@twilio-paste/box"; import { Button } from "@twilio-paste/button"; import { ButtonGroup } from "@twilio-paste/button-group"; +import { Callout, CalloutHeading, CalloutText } from "@twilio-paste/callout"; +import { CodeBlock, CodeBlockHeader, CodeBlockWrapper } from "@twilio-paste/code-block"; import { Disclosure, DisclosureContent, DisclosureHeading } from "@twilio-paste/disclosure"; import { Heading } from "@twilio-paste/heading"; import { CopyIcon } from "@twilio-paste/icons/esm/CopyIcon"; @@ -23,7 +26,6 @@ import { AIChatMessageActionGroup, AIChatMessageAuthor, AIChatMessageBody, - AIChatMessageBodyTypeWriter, AIChatMessageLoading, } from "../src"; @@ -78,7 +80,7 @@ export const AIMessageLoading = (): React.ReactNode => { return ( - +

Pssst! The three rows have dynamic widths. Refresh to see it in action!

@@ -159,7 +161,7 @@ export const FullAIMessage = (): React.ReactNode => { Good Bot - + Lorem ipsum dolor, sit amet consectetur adipisicing elit. Deserunt delectus fuga, necessitatibus eligendiiure adipisci facilis exercitationem officiis dolorem laborum, ex fugiat quisquam itaque, earum sit nesciunt impedit repellat assumenda. @@ -211,62 +213,102 @@ export const FullAIMessage = (): React.ReactNode => { ); }; -export const MessageBodyTypeWriter = (): React.ReactNode => { +const rubyCode = `#!/usr/bin/ruby + +# Import the library. This is a really really long line that should be wrapped. +require 'tk' + +# Root window. +root = TkRoot.new { + title 'Push Me' + background '#111188' +} + +# Add a label to the root window. +lab = TkLabel.new(root) { + text "Hey there,\nPush a button!" + background '#3333AA' + foreground '#CCCCFF' +} +`; + +export const MessageBodyTypeWriterEnrichedText = (): React.ReactNode => { return ( With enriched text - - - Lorem ipsum dolor, sit amet consectetur adipisicing elit. Deserunt - delectus fuga, necessitatibus eligendiiure adipisci facilis exercitationem officiis dolorem laborum, ex - fugiat quisquam itaque, earum sit nesciunt impedit repellat assumenda. new - text,{" "} - - 434324 - - + + Lorem ipsum dolor, sit amet consectetur adipisicing elit. Deserunt + delectus fuga, necessitatibus eligendiiure adipisci facilis exercitationem officiis dolorem laborum, ex fugiat + quisquam itaque, earum sit nesciunt impedit repellat assumenda. new text,{" "} + + 434324 + Item 1 Item 2 Item 3 - - - Between the World and Me by Ta-Nehisi Coates - - - But race is the child of racism, not the father. And the process of naming “the people” has never been a - matter of genealogy and physiognomy so much as one of hierarchy. Difference in hue and hair is old. But - the belief in the preeminence of hue and hair, the notion that these factors can correctly organize a - society and that they signify deeper attributes, which are indelible—this is the new idea at the heart of - these new people who have been brought up hopelessly, tragically, deceitfully, to believe that they are - white. - - - -
- -
- {/* - Without enriched text [fullscreen variant]: - +
+ ); +}; - - - Lorem ipsum dolor, sit amet consectetur adipisicing elit. Deserunt delectus fuga, necessitatibus eligendiiure - adipisci facilis exercitationem officiis dolorem laborum, ex fugiat quisquam itaque, earum sit - - - Item 1 - Item 2 - Item 3 - - */} +export const MessageBodyTypeWriterComplexComponents = (): React.ReactNode => { + return ( + + + With complex components + + + + + + This + is text that contains icons between elements + + + +
+ + With AI-driven products, the design process is no longer just about aesthetics. It’s about designing for + the human experience as a whole. + + +
+
+ + + Heads up! + This is some information you need to know. + + + + + Build a button + + + + + + + Between the World and Me by Ta-Nehisi Coates + + + But race is the child of racism, not the father. And the process of naming “the people” has never been a + matter of genealogy and physiognomy so much as one of hierarchy. Difference in hue and hair is old. But + the belief in the preeminence of hue and hair, the notion that these factors can correctly organize a + society and that they signify deeper attributes, which are indelible—this is the new idea at the heart + of these new people who have been brought up hopelessly, tragically, deceitfully, to believe that they + are white. + + + +
+
); }; diff --git a/packages/paste-core/components/ai-chat-log/stories/useAIChatLogger.stories.tsx b/packages/paste-core/components/ai-chat-log/stories/useAIChatLogger.stories.tsx index 3c443c64b0..3fe85ba619 100644 --- a/packages/paste-core/components/ai-chat-log/stories/useAIChatLogger.stories.tsx +++ b/packages/paste-core/components/ai-chat-log/stories/useAIChatLogger.stories.tsx @@ -105,7 +105,7 @@ export const UseChatLogger: StoryFn = () => { {isBot ? "Good Bot" : "Gibby Radki"} - {message} + {message}
), }; From 59470a627cc95328db1e8c6386f74ae61ab822e1 Mon Sep 17 00:00:00 2001 From: Kristian Antrobus Date: Thu, 19 Dec 2024 11:25:39 -0600 Subject: [PATCH 04/13] feat(docs): udpate docs and changesets --- .changeset/tough-moles-film.md | 6 +++ .../components/ai-chat-log/src/index.tsx | 2 - .../components/ai-chat-log/type-docs.json | 7 +++ .../component-examples/AIChatLogExamples.ts | 51 +++++++++++++++++++ .../pages/components/ai-chat-log/index.mdx | 32 ++++++++++++ 5 files changed, 96 insertions(+), 2 deletions(-) create mode 100644 .changeset/tough-moles-film.md diff --git a/.changeset/tough-moles-film.md b/.changeset/tough-moles-film.md new file mode 100644 index 0000000000..467785d164 --- /dev/null +++ b/.changeset/tough-moles-film.md @@ -0,0 +1,6 @@ +--- +"@twilio-paste/ai-chat-log": minor +"@twilio-paste/core": minor +--- + +[AI Chat Log] added optional typewriter animation to AIChatMessageBody diff --git a/packages/paste-core/components/ai-chat-log/src/index.tsx b/packages/paste-core/components/ai-chat-log/src/index.tsx index a477cf5271..cf8f5b65eb 100644 --- a/packages/paste-core/components/ai-chat-log/src/index.tsx +++ b/packages/paste-core/components/ai-chat-log/src/index.tsx @@ -10,8 +10,6 @@ export { AIChatMessageActionCard } from "./AIChatMessageActionCard"; export type { AIChatMessageActionCardProps } from "./AIChatMessageActionCard"; export { AIChatMessageLoading } from "./AIChatMessageLoading"; export type { AIChatMessageLoadingProps } from "./AIChatMessageLoading"; -export { AIChatMessageBodyTypeWriter } from "./AIChatMessageBodyTypeWriter"; -export type { AIChatMessageBodyTypeWriterProps } from "./AIChatMessageBodyTypeWriter"; export { AIChatLog } from "./AIChatLog"; export type { AIChatLogProps } from "./AIChatLog"; diff --git a/packages/paste-core/components/ai-chat-log/type-docs.json b/packages/paste-core/components/ai-chat-log/type-docs.json index b5f4f87b59..31ec9b4886 100644 --- a/packages/paste-core/components/ai-chat-log/type-docs.json +++ b/packages/paste-core/components/ai-chat-log/type-docs.json @@ -3186,6 +3186,13 @@ "required": false, "externalProp": true }, + "animated": { + "type": "boolean", + "defaultValue": false, + "required": false, + "externalProp": false, + "description": "Whether the text should be animated with type writer effect" + }, "aria-activedescendant": { "type": "string", "defaultValue": null, diff --git a/packages/paste-website/src/component-examples/AIChatLogExamples.ts b/packages/paste-website/src/component-examples/AIChatLogExamples.ts index ff9e831a7e..2c21c895e0 100644 --- a/packages/paste-website/src/component-examples/AIChatLogExamples.ts +++ b/packages/paste-website/src/component-examples/AIChatLogExamples.ts @@ -712,3 +712,54 @@ const SystemError = () => { render( )`.trim(); + +export const animatedBotWithFeedback = ` +const AnimatedMessageWithFeedback = () => { + const [animated, setAnimated] = React.useState(true) + + const restart = () => { + setAnimated(false) + setTimeout(() => { + setAnimated(true) + }, 100) + } + + return ( + <> + + + + Good Bot + + I found multiple solutions for the issue with your environment variable, TWILIO_AUTH_TOKEN. Other helpful resources can be found at Twilio API Docs. + + + + Is this helpful? + + + + + + + + + + + + ); +}; + +render( + +)`.trim(); diff --git a/packages/paste-website/src/pages/components/ai-chat-log/index.mdx b/packages/paste-website/src/pages/components/ai-chat-log/index.mdx index feb662d0cb..e9f69e92e4 100644 --- a/packages/paste-website/src/pages/components/ai-chat-log/index.mdx +++ b/packages/paste-website/src/pages/components/ai-chat-log/index.mdx @@ -22,6 +22,7 @@ import { Button } from "@twilio-paste/button"; import { ButtonGroup } from "@twilio-paste/button-group"; import { HelpText } from "@twilio-paste/help-text"; import { CopyIcon } from "@twilio-paste/icons/esm/CopyIcon"; +import { ResetIcon } from "@twilio-paste/icons/esm/ResetIcon"; import { LogoTwilioIcon } from "@twilio-paste/icons/esm/LogoTwilioIcon"; import { RefreshIcon } from "@twilio-paste/icons/esm/RefreshIcon"; import { ThumbsDownIcon } from "@twilio-paste/icons/esm/ThumbsDownIcon"; @@ -30,6 +31,7 @@ import { Paragraph } from "@twilio-paste/paragraph"; import { Stack } from "@twilio-paste/stack"; import { uid } from "@twilio-paste/uid-library"; + import Logo from "../../../assets/logo.svg"; import { SidebarCategoryRoutes } from "../../../constants"; @@ -58,11 +60,14 @@ import { messageGenerationError, sendingMessageError, systemError, + animatedBotWithFeedback } from "../../../component-examples/AIChatLogExamples"; import ComponentPageLayout from "../../../layouts/ComponentPageLayout"; import { getFeature, getNavigationData } from "../../../utils/api"; import { Text } from "@twilio-paste/text"; import { Alert } from "@twilio-paste/alert"; +import { Switch } from "@twilio-paste/switch"; +import { InlineCode } from "@twilio-paste/inline-code"; export default ComponentPageLayout; @@ -201,6 +206,33 @@ The `AIChatMessageBody` component has two sizes, `size="default"` and `size="ful
`} +### Messages with animation +The `AIChatMessageBody` component has an optional `animate` prop that can be used to apply a typewriter animation to the text. . + + + {animatedBotWithFeedback} + + ### Message with Actions Message actions can be used to provide quick responses or actions to the user. From 7cf6a46a27b6d8af3b3df0273bc5553e0d1b2972 Mon Sep 17 00:00:00 2001 From: Kristian Antrobus Date: Thu, 19 Dec 2024 11:43:59 -0600 Subject: [PATCH 05/13] chore(ai-chat-log): linting --- .../components/ai-chat-log/src/AIChatMessageBody.tsx | 2 +- packages/paste-core/components/ai-chat-log/src/utils.tsx | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/paste-core/components/ai-chat-log/src/AIChatMessageBody.tsx b/packages/paste-core/components/ai-chat-log/src/AIChatMessageBody.tsx index 67a4015824..87f2699bae 100644 --- a/packages/paste-core/components/ai-chat-log/src/AIChatMessageBody.tsx +++ b/packages/paste-core/components/ai-chat-log/src/AIChatMessageBody.tsx @@ -4,7 +4,7 @@ import type { HTMLPasteProps } from "@twilio-paste/types"; import * as React from "react"; import { AIMessageContext } from "./AIMessageContext"; -import useAnimatedText from "./utils"; +import { useAnimatedText } from "./utils"; const Sizes: Record = { default: { diff --git a/packages/paste-core/components/ai-chat-log/src/utils.tsx b/packages/paste-core/components/ai-chat-log/src/utils.tsx index e6b3adee4a..0057f54b85 100644 --- a/packages/paste-core/components/ai-chat-log/src/utils.tsx +++ b/packages/paste-core/components/ai-chat-log/src/utils.tsx @@ -15,9 +15,9 @@ export const useAnimatedText = (children: React.ReactNode, speed = 10): React.Re }, [speed]); // Function to calculate the total length of text within nested elements - const calculateTotalTextLength = (children: React.ReactNode): number => { + const calculateTotalTextLength = (nodes: React.ReactNode): number => { let length = 0; - React.Children.forEach(children, (child) => { + React.Children.forEach(nodes, (child) => { if (typeof child === "string") { length += child.length; } else if (React.isValidElement(child)) { @@ -28,9 +28,9 @@ export const useAnimatedText = (children: React.ReactNode, speed = 10): React.Re }; // Function to recursively clone children and apply text animation - const cloneChildren = (children: React.ReactNode, currentIndex: number): React.ReactNode => { + const cloneChildren = (nodes: React.ReactNode, currentIndex: number): React.ReactNode => { let currentTextIndex = currentIndex; - return React.Children.map(children, (child) => { + return React.Children.map(nodes, (child) => { if (typeof child === "string") { // Only include text nodes if their animation has started if (currentTextIndex > 0) { From da4b8d0b78b9e9836708c63d026187d80d27869a Mon Sep 17 00:00:00 2001 From: Kristian Antrobus Date: Thu, 19 Dec 2024 11:52:19 -0600 Subject: [PATCH 06/13] docs(ai-chat-log): update definition --- .../paste-website/src/pages/components/ai-chat-log/index.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/paste-website/src/pages/components/ai-chat-log/index.mdx b/packages/paste-website/src/pages/components/ai-chat-log/index.mdx index e9f69e92e4..0e20defc99 100644 --- a/packages/paste-website/src/pages/components/ai-chat-log/index.mdx +++ b/packages/paste-website/src/pages/components/ai-chat-log/index.mdx @@ -207,7 +207,7 @@ The `AIChatMessageBody` component has two sizes, `size="default"` and `size="ful ### Messages with animation -The `AIChatMessageBody` component has an optional `animate` prop that can be used to apply a typewriter animation to the text. . +The `AIChatMessageBody` component has an optional `animate` prop that can be used to apply a typewriter animation to the text. This should only be applied to the most recent message recieved from the AI. Date: Thu, 19 Dec 2024 11:54:29 -0600 Subject: [PATCH 07/13] docs(ai-chat-log): docs spelling --- .../paste-website/src/pages/components/ai-chat-log/index.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/paste-website/src/pages/components/ai-chat-log/index.mdx b/packages/paste-website/src/pages/components/ai-chat-log/index.mdx index 0e20defc99..68b53efbe6 100644 --- a/packages/paste-website/src/pages/components/ai-chat-log/index.mdx +++ b/packages/paste-website/src/pages/components/ai-chat-log/index.mdx @@ -207,7 +207,7 @@ The `AIChatMessageBody` component has two sizes, `size="default"` and `size="ful ### Messages with animation -The `AIChatMessageBody` component has an optional `animate` prop that can be used to apply a typewriter animation to the text. This should only be applied to the most recent message recieved from the AI. +The `AIChatMessageBody` component has an optional `animate` prop that can be used to apply a typewriter animation to the text. This should only be applied to the most recent message received from the AI. Date: Mon, 13 Jan 2025 10:37:52 -0600 Subject: [PATCH 08/13] feat(ai-chat-log): typewiter speeds --- .../ai-chat-log/src/AIChatMessageBody.tsx | 3 +- .../ai-chat-log/stories/parts.stories.tsx | 94 ++++++++++++++++++- 2 files changed, 95 insertions(+), 2 deletions(-) diff --git a/packages/paste-core/components/ai-chat-log/src/AIChatMessageBody.tsx b/packages/paste-core/components/ai-chat-log/src/AIChatMessageBody.tsx index 87f2699bae..635d18ab3f 100644 --- a/packages/paste-core/components/ai-chat-log/src/AIChatMessageBody.tsx +++ b/packages/paste-core/components/ai-chat-log/src/AIChatMessageBody.tsx @@ -49,7 +49,8 @@ export interface AIChatMessageBodyProps extends HTMLPasteProps<"div"> { export const AIChatMessageBody = React.forwardRef( ({ children, size = "default", element = "AI_CHAT_MESSAGE_BODY", animated = false, ...props }, ref) => { const { id } = React.useContext(AIMessageContext); - const childrenToRender = animated ? useAnimatedText(children) : children; + const animationSpeed = size === "fullScreen" ? 8 : 10; + const childrenToRender = animated ? useAnimatedText(children, animationSpeed) : children; return ( { ); }; + +export const MessageBodyTypeWriterFullscreen = (): React.ReactNode => { + return ( + + Lorem ipsum dolor, sit amet consectetur adipisicing elit. Deserunt + delectus fuga, necessitatibus eligendiiure adipisci facilis exercitationem officiis dolorem laborum, ex fugiat + quisquam itaque, earum sit nesciunt impedit repellat assumenda. new text,{" "} + + 434324 + + + Item 1 + Item 2 + Item 3 + + + ); +}; + +export const MessageBodyTypeWriterDefaultSidePanel: StoryFn = () => { + const [isOpen, setIsOpen] = React.useState(true); + return ( + + + + + + Assistant + + + + + + + + Good Bot + + Lorem ipsum dolor, sit amet consectetur adipisicing elit.{" "} + Deserunt delectus fuga, necessitatibus eligendiiure adipisci facilis exercitationem officiis dolorem + laborum, ex fugiat quisquam itaque, earum sit{" "} + nesciunt impedit repellat assumenda. new text,{" "} + + 434324 + + + Item 1 + Item 2 + Item 3 + + + + + + + + + + Toggle Side Panel + + + + ); +}; +MessageBodyTypeWriterDefaultSidePanel.parameters = { + padding: false, + a11y: { + config: { + rules: [ + { + /* + * Using position="relative" on SidePanel causes it to overflow other themes in stacked and side-by-side views, and therefore fail color contrast checks based on SidePanelBody's content. + * The DefaultVRT test below serves to test color contrast on the Side Panel component without this issue causing false failures. + */ + id: "color-contrast", + selector: "*:not(*)", + }, + ], + }, + }, +}; From a0f40c38a026a3899818c346da4e74f038ee97c9 Mon Sep 17 00:00:00 2001 From: Kristian Antrobus Date: Tue, 14 Jan 2025 11:17:54 -0600 Subject: [PATCH 09/13] chore(docs): rearrange ai chat log sections --- .../pages/components/ai-chat-log/index.mdx | 62 +++++++++---------- 1 file changed, 31 insertions(+), 31 deletions(-) diff --git a/packages/paste-website/src/pages/components/ai-chat-log/index.mdx b/packages/paste-website/src/pages/components/ai-chat-log/index.mdx index 68b53efbe6..45cd7524c9 100644 --- a/packages/paste-website/src/pages/components/ai-chat-log/index.mdx +++ b/packages/paste-website/src/pages/components/ai-chat-log/index.mdx @@ -206,33 +206,6 @@ The `AIChatMessageBody` component has two sizes, `size="default"` and `size="ful
`} -### Messages with animation -The `AIChatMessageBody` component has an optional `animate` prop that can be used to apply a typewriter animation to the text. This should only be applied to the most recent message received from the AI. - - - {animatedBotWithFeedback} - - ### Message with Actions Message actions can be used to provide quick responses or actions to the user. @@ -283,14 +256,14 @@ Actions can still be added in `AIChatMessageBody` which are returned from the AI {botWithBodyActions} -### Loading States +### Generating Messages + +#### Loading Use the `AIChatMessageLoading` component to indicate that the bot is typing or processing a response. During this time **no user input should be accepted**. No new messages should be added to a chat until the AI operation is finished processing. The SkeletonLoader lengths vary on each render to give a more natural pending message body interaction. -#### Loading - -#### Loading with Stop Button +##### Loading with Stop Button +### Animating +The `AIChatMessageBody` component has an optional `animated` prop that can be used to apply a typewriter animation to the text. This should only be applied to the most recent message received from the AI. + + + {animatedBotWithFeedback} + + ### Customizing Avatar `AIChatMessageAuthor` can be customized by passing an icon, image, or string to the `avatarIcon`, `avatarSrc`, or `avatarName` props. [Learn more about the API](/components/ai-chat-log/api#aichatmessageauthor). From e8c18748fa2bc8612b4f2ff6d99e30843482e547 Mon Sep 17 00:00:00 2001 From: Kristian Antrobus Date: Tue, 14 Jan 2025 14:44:56 -0600 Subject: [PATCH 10/13] docs(ai-chat-log): added scollable example --- .../components/ai-chat-log/src/utils.tsx | 21 +- .../ai-chat-log/stories/composer.stories.tsx | 2 +- .../stories/scrollableSidePanel.stories.tsx | 359 ++++++++++++++++++ .../component-examples/AIChatLogExamples.ts | 72 ++++ .../pages/components/ai-chat-log/index.mdx | 25 +- 5 files changed, 470 insertions(+), 9 deletions(-) create mode 100644 packages/paste-core/components/ai-chat-log/stories/scrollableSidePanel.stories.tsx diff --git a/packages/paste-core/components/ai-chat-log/src/utils.tsx b/packages/paste-core/components/ai-chat-log/src/utils.tsx index 0057f54b85..92adfe94b4 100644 --- a/packages/paste-core/components/ai-chat-log/src/utils.tsx +++ b/packages/paste-core/components/ai-chat-log/src/utils.tsx @@ -1,7 +1,11 @@ import React, { useEffect, useState } from "react"; // Hook to animate text content of React elements -export const useAnimatedText = (children: React.ReactNode, speed = 10): React.ReactNode => { +export const useAnimatedText = ( + children: React.ReactNode, + speed = 10, + enabled = true, +): { animatedChildren: React.ReactNode; isAnimating: boolean } => { const [animatedChildren, setAnimatedChildren] = useState(); const [textIndex, setTextIndex] = useState(0); @@ -58,13 +62,18 @@ export const useAnimatedText = (children: React.ReactNode, speed = 10): React.Re // Effect to update animated children based on the current text index useEffect(() => { - const totaLength = calculateTotalTextLength(children); - if (textIndex <= totaLength) { - setAnimatedChildren(cloneChildren(children, textIndex)); + if (enabled) { + const totaLength = calculateTotalTextLength(children); + if (textIndex <= totaLength) { + setAnimatedChildren(cloneChildren(children, textIndex)); + } } - }, [children, textIndex]); + }, [children, textIndex, enabled]); - return animatedChildren; + return { + animatedChildren: enabled ? animatedChildren : children, + isAnimating: enabled && textIndex < calculateTotalTextLength(children), + }; }; export default useAnimatedText; diff --git a/packages/paste-core/components/ai-chat-log/stories/composer.stories.tsx b/packages/paste-core/components/ai-chat-log/stories/composer.stories.tsx index f1883b2d1a..39f1312430 100644 --- a/packages/paste-core/components/ai-chat-log/stories/composer.stories.tsx +++ b/packages/paste-core/components/ai-chat-log/stories/composer.stories.tsx @@ -59,7 +59,7 @@ const BotMessage = (props): JSX.Element => { ) : ( Good Bot - {props.message as string} + {props.message as string} ); }; diff --git a/packages/paste-core/components/ai-chat-log/stories/scrollableSidePanel.stories.tsx b/packages/paste-core/components/ai-chat-log/stories/scrollableSidePanel.stories.tsx new file mode 100644 index 0000000000..6ba5d87e88 --- /dev/null +++ b/packages/paste-core/components/ai-chat-log/stories/scrollableSidePanel.stories.tsx @@ -0,0 +1,359 @@ +/* eslint-disable eslint-comments/disable-enable-pair */ +/* eslint-disable react/jsx-max-depth */ +/* eslint-disable import/no-extraneous-dependencies */ +import { StoryFn } from "@storybook/react"; +import { Anchor } from "@twilio-paste/anchor"; +import { Box } from "@twilio-paste/box"; +import { Button } from "@twilio-paste/button"; +import { ButtonGroup } from "@twilio-paste/button-group"; +import { ChatComposer, ChatComposerActionGroup, ChatComposerContainer } from "@twilio-paste/chat-composer"; +import { Heading } from "@twilio-paste/heading"; +import { ArtificialIntelligenceIcon } from "@twilio-paste/icons/esm/ArtificialIntelligenceIcon"; +import { SendIcon } from "@twilio-paste/icons/esm/SendIcon"; +import { ThumbsDownIcon } from "@twilio-paste/icons/esm/ThumbsDownIcon"; +import { ThumbsUpIcon } from "@twilio-paste/icons/esm/ThumbsUpIcon"; +import { + $getRoot, + CLEAR_EDITOR_COMMAND, + COMMAND_PRIORITY_HIGH, + ClearEditorPlugin, + KEY_ENTER_COMMAND, + LexicalEditor, + useLexicalComposerContext, +} from "@twilio-paste/lexical-library"; +import { ListItem, UnorderedList } from "@twilio-paste/list"; +import { Separator } from "@twilio-paste/separator"; +import { + SidePanel, + SidePanelBody, + SidePanelButton, + SidePanelContainer, + SidePanelFooter, + SidePanelHeader, + SidePanelPushContentWrapper, +} from "@twilio-paste/side-panel"; + +import * as React from "react"; + +import { + AIChat, + AIChatLog, + AIChatLogger, + AIChatMessage, + AIChatMessageActionCard, + AIChatMessageActionGroup, + AIChatMessageAuthor, + AIChatMessageBody, + AIChatMessageLoading, + useAIChatLogger, +} from "../src"; + +// eslint-disable-next-line import/no-default-export +export default { + title: "Components/AI Chat Log", + component: AIChatLog, +}; + +function getRandomInt(max: number): number { + return Math.floor(Math.random() * max); +} + +const BotMessage = (props: any): JSX.Element => { + const [isLoading, setIsLoading] = React.useState(true); + + setTimeout(() => { + setIsLoading(false); + }, 3000); + return isLoading ? ( + + Good Bot + { + setIsLoading(false); + }} + /> + + ) : ( + + Good Bot + + {props.message} + + + ); +}; + +const EnterKeySubmitPlugin = ({ onKeyDown }: { onKeyDown: () => void }): null => { + const [editor] = useLexicalComposerContext(); + + const handleEnterKey = React.useCallback( + (event: KeyboardEvent) => { + const { shiftKey, ctrlKey } = event; + if (shiftKey || ctrlKey) return false; + event.preventDefault(); + event.stopPropagation(); + onKeyDown(); + editor.dispatchCommand(CLEAR_EDITOR_COMMAND, undefined); + return true; + }, + [editor, onKeyDown], + ); + + React.useEffect(() => { + return editor.registerCommand(KEY_ENTER_COMMAND, handleEnterKey, COMMAND_PRIORITY_HIGH); + }, [editor, handleEnterKey]); + return null; +}; + +export const SidePanelScroll: StoryFn = () => { + const [isOpen, setIsOpen] = React.useState(true); + const [isAnimating, setIsAnimating] = React.useState(false); + const { aiChats, push } = useAIChatLogger( + { + variant: "user", + content: ( + + Gibby Radki + Hi, I am getting errors codes when sending an SMS. + + ), + }, + { + variant: "bot", + content: ( + + Good Bot + + Error codes can be returned from various parts of the process. What error codes are you encountering? + + + + + + + + + + + Is this helpful? + + + + + + ), + }, + { + variant: "user", + content: ( + + Gibby Radki + I am getting the error 30007 when attemptin to send a mass message. + + ), + }, + { + variant: "bot", + content: ( + + Good Bot + + This is an indicator that the message was filtered (blocked) by Twilio or by the carrier. This may be done + by Twilio for violating Twilio's{" "} + + Messaging Policy + {" "} + or{" "} + + Acceptable Use Policy + + , or by a wireless carrier for violating carrier rules or regulations. + + + + Is this helpful? + + + + + + ), + }, + ); + const [message, setMessage] = React.useState(""); + const [mounted, setMounted] = React.useState(false); + const loggerRef = React.useRef(null); + const scrollerRef = React.useRef(null); + + React.useEffect(() => { + setMounted(true); + }, []); + + const scrollToChatEnd = () => { + const scrollPosition: any = scrollerRef.current; + const scrollHeight: any = loggerRef.current; + scrollPosition?.scrollTo({ top: scrollHeight.scrollHeight, behavior: "smooth" }); + }; + + React.useEffect(() => { + if (!mounted || !loggerRef.current) return; + scrollToChatEnd(); + }, [aiChats, mounted]); + + const handleComposerChange = (editorState: any): void => { + editorState.read(() => { + const text = $getRoot().getTextContent(); + setMessage(text); + }); + }; + + const onAnimationEnd = () => { + setIsAnimating(false); + scrollToChatEnd(); + }; + + const onAnimationStart = () => { + setIsAnimating(true); + }; + + React.useEffect(() => { + const interval = setInterval(() => isAnimating && scrollToChatEnd(), 30); + + return () => { + if (interval) clearInterval(interval); + }; + }, [isAnimating]); + + // eslint-disable-next-line storybook/prefer-pascal-case + const createNewMessage = (message: any, forceBot?: boolean): Omit => { + const messageDirection = forceBot ? "bot" : getRandomInt(2) === 1 ? "user" : "bot"; + + return { + variant: messageDirection, + content: + messageDirection === "user" ? ( + + Gibby Radki + {message} + + ) : ( + + + + ), + }; + }; + + const submitMessage = (): void => { + if (message === "") return; + push(createNewMessage(message)); + }; + + const pushLargeBotMessage = () => { + push( + createNewMessage( + <> + Lorem ipsum dolor, sit amet consectetur adipisicing elit. Deserunt + delectus fuga, necessitatibus eligendiiure adipisci facilis exercitationem officiis dolorem laborum, ex fugiat + quisquam itaque, earum sit nesciunt impedit repellat assumenda. new text,{" "} + + 434324 + + + Item 1 + Item 2 + Item 3 + + Lorem ipsum dolor, sit amet consectetur adipisicing elit. Deserunt + delectus fuga, necessitatibus eligendiiure adipisci facilis exercitationem officiis dolorem laborum, ex fugiat + quisquam itaque, earum sit nesciunt impedit repellat assumenda. new text,{" "} + + 434324 + + , + true, + ), + ); + }; + + const editorInstanceRef = React.useRef(null); + + return ( + + + + + + Assistant + + + + + + + + + + + { + throw e; + }, + }} + placeholder="Type here..." + ariaLabel="Chat input" + editorInstanceRef={editorInstanceRef} + onChange={handleComposerChange} + > + + + + + + + + + + + + Toggle Side Panel + + + + + ); +}; +SidePanelScroll.parameters = { + padding: false, + a11y: { + disable: true, + }, +}; diff --git a/packages/paste-website/src/component-examples/AIChatLogExamples.ts b/packages/paste-website/src/component-examples/AIChatLogExamples.ts index 2c21c895e0..70b0e0e971 100644 --- a/packages/paste-website/src/component-examples/AIChatLogExamples.ts +++ b/packages/paste-website/src/component-examples/AIChatLogExamples.ts @@ -763,3 +763,75 @@ const AnimatedMessageWithFeedback = () => { render( )`.trim(); + +export const animatedBotScrollable = ` +const exampleAIResponseText = + "Twilio error codes are numeric codes returned by the Twilio API when an error occurs during a request, providing specific information about the problem encountered, such as invalid phone numbers, network issues, or authentication failures; they help developers identify and troubleshoot issues within their applications using Twilio services"; + +const AnimatedBotScrollable = () => { + const [isAnimating, setIsAnimating] = React.useState(false); + const loggerRef = React.useRef(null); + const scrollerRef = React.useRef(null); + + const { aiChats, push } = useAIChatLogger({ + variant: "bot", + content: ( + + Good Bot + {exampleAIResponseText} + + ), + }); + + const scrollToChatEnd = () => { + const scrollPosition = scrollerRef.current; + const scrollHeight = loggerRef.current; + scrollPosition?.scrollTo({ top: scrollHeight.scrollHeight, behavior: "smooth" }); + }; + + const onAnimationEnd = () => { + setIsAnimating(false); + scrollToChatEnd(); + }; + + const onAnimationStart = () => { + setIsAnimating(true); + }; + + React.useEffect(() => { + const interval = setInterval(() => isAnimating && scrollToChatEnd(), 30); + + return () => { + if (interval) clearInterval(interval); + }; + }, [isAnimating]); + + const pushLargeBotMessage = () => { + push({ + variant: "bot", + content: ( + + Good Bot + + {exampleAIResponseText} + + + ), + }); + }; + + return ( + + + + + + + ); +}; + +render( + +)`.trim(); diff --git a/packages/paste-website/src/pages/components/ai-chat-log/index.mdx b/packages/paste-website/src/pages/components/ai-chat-log/index.mdx index 45cd7524c9..91cfce6180 100644 --- a/packages/paste-website/src/pages/components/ai-chat-log/index.mdx +++ b/packages/paste-website/src/pages/components/ai-chat-log/index.mdx @@ -60,7 +60,8 @@ import { messageGenerationError, sendingMessageError, systemError, - animatedBotWithFeedback + animatedBotWithFeedback, + animatedBotScrollable } from "../../../component-examples/AIChatLogExamples"; import ComponentPageLayout from "../../../layouts/ComponentPageLayout"; import { getFeature, getNavigationData } from "../../../utils/api"; @@ -294,7 +295,7 @@ The SkeletonLoader lengths vary on each render to give a more natural pending me {botWithLoadingStopButton} -### Animating +#### Animating The `AIChatMessageBody` component has an optional `animated` prop that can be used to apply a typewriter animation to the text. This should only be applied to the most recent message received from the AI. +##### Scrolling +The `AIChatMessageBody` component has an optional `animated` prop that can be used to apply a typewriter animation to the text. This should only be applied to the most recent message received from the AI. + + + {animatedBotScrollable} + + + ### Customizing Avatar `AIChatMessageAuthor` can be customized by passing an icon, image, or string to the `avatarIcon`, `avatarSrc`, or `avatarName` props. [Learn more about the API](/components/ai-chat-log/api#aichatmessageauthor). From 21ff80226e8a689ce5936752322b26a6db7e1a6a Mon Sep 17 00:00:00 2001 From: Kristian Antrobus Date: Tue, 14 Jan 2025 14:56:57 -0600 Subject: [PATCH 11/13] docs(ai-chat-log): modified scrollable exmaple --- .../ai-chat-log/src/AIChatMessageBody.tsx | 44 +++++++++++++++++-- .../pages/components/ai-chat-log/index.mdx | 30 +------------ 2 files changed, 43 insertions(+), 31 deletions(-) diff --git a/packages/paste-core/components/ai-chat-log/src/AIChatMessageBody.tsx b/packages/paste-core/components/ai-chat-log/src/AIChatMessageBody.tsx index 635d18ab3f..d59add5634 100644 --- a/packages/paste-core/components/ai-chat-log/src/AIChatMessageBody.tsx +++ b/packages/paste-core/components/ai-chat-log/src/AIChatMessageBody.tsx @@ -44,13 +44,51 @@ export interface AIChatMessageBodyProps extends HTMLPasteProps<"div"> { * @memberof AIChatMessageBodyProps */ animated?: boolean; + /** + * A callback when the animation is started + * + * @default false + * @type {() => void} + * @memberof AIChatMessageBodyProps + */ + onAnimationStart?: () => void; + /** + * A callback when the animation is complete + * + * @default false + * @type {() => void} + * @memberof AIChatMessageBodyProps + */ + onAnimationEnd?: () => void; } export const AIChatMessageBody = React.forwardRef( - ({ children, size = "default", element = "AI_CHAT_MESSAGE_BODY", animated = false, ...props }, ref) => { + ( + { + children, + size = "default", + element = "AI_CHAT_MESSAGE_BODY", + animated = false, + onAnimationEnd, + onAnimationStart, + ...props + }, + ref, + ) => { const { id } = React.useContext(AIMessageContext); + const [showAnimation] = React.useState(animated && children !== undefined); const animationSpeed = size === "fullScreen" ? 8 : 10; - const childrenToRender = animated ? useAnimatedText(children, animationSpeed) : children; + const { animatedChildren, isAnimating } = useAnimatedText(children, animationSpeed, showAnimation); + + React.useEffect(() => { + if (onAnimationStart && animated && isAnimating) { + onAnimationStart(); + } + + if (animated && !isAnimating && onAnimationEnd) { + onAnimationEnd(); + } + }, [isAnimating, showAnimation]); return ( - {childrenToRender} + {animatedChildren} ); }, diff --git a/packages/paste-website/src/pages/components/ai-chat-log/index.mdx b/packages/paste-website/src/pages/components/ai-chat-log/index.mdx index 91cfce6180..21f6ace080 100644 --- a/packages/paste-website/src/pages/components/ai-chat-log/index.mdx +++ b/packages/paste-website/src/pages/components/ai-chat-log/index.mdx @@ -60,7 +60,6 @@ import { messageGenerationError, sendingMessageError, systemError, - animatedBotWithFeedback, animatedBotScrollable } from "../../../component-examples/AIChatLogExamples"; import ComponentPageLayout from "../../../layouts/ComponentPageLayout"; @@ -296,34 +295,9 @@ The SkeletonLoader lengths vary on each render to give a more natural pending me #### Animating -The `AIChatMessageBody` component has an optional `animated` prop that can be used to apply a typewriter animation to the text. This should only be applied to the most recent message received from the AI. +The `AIChatMessageBody` component has an optional `animated` prop that can be used to apply a typewriter animation to the text. This should only be applied to the messages received from the AI. - - {animatedBotWithFeedback} - - -##### Scrolling -The `AIChatMessageBody` component has an optional `animated` prop that can be used to apply a typewriter animation to the text. This should only be applied to the most recent message received from the AI. +It also accepts `onAnimationStart` and `onAnimationEnd` props to trigger actions when the animation starts and ends allowing additional logic such as scrolling to be implemented. Date: Tue, 14 Jan 2025 15:11:13 -0600 Subject: [PATCH 12/13] chore(ai-chat-log): typedocs --- .../stories/scrollableSidePanel.stories.tsx | 19 +++++++++---------- .../components/ai-chat-log/type-docs.json | 14 ++++++++------ 2 files changed, 17 insertions(+), 16 deletions(-) diff --git a/packages/paste-core/components/ai-chat-log/stories/scrollableSidePanel.stories.tsx b/packages/paste-core/components/ai-chat-log/stories/scrollableSidePanel.stories.tsx index 6ba5d87e88..7bb4d0f742 100644 --- a/packages/paste-core/components/ai-chat-log/stories/scrollableSidePanel.stories.tsx +++ b/packages/paste-core/components/ai-chat-log/stories/scrollableSidePanel.stories.tsx @@ -32,7 +32,6 @@ import { SidePanelHeader, SidePanelPushContentWrapper, } from "@twilio-paste/side-panel"; - import * as React from "react"; import { @@ -169,7 +168,7 @@ export const SidePanelScroll: StoryFn = () => { Good Bot This is an indicator that the message was filtered (blocked) by Twilio or by the carrier. This may be done - by Twilio for violating Twilio's{" "} + by Twilio for violating Twilio&aposs{" "} Messaging Policy {" "} @@ -203,7 +202,7 @@ export const SidePanelScroll: StoryFn = () => { setMounted(true); }, []); - const scrollToChatEnd = () => { + const scrollToChatEnd = (): void => { const scrollPosition: any = scrollerRef.current; const scrollHeight: any = loggerRef.current; scrollPosition?.scrollTo({ top: scrollHeight.scrollHeight, behavior: "smooth" }); @@ -221,12 +220,12 @@ export const SidePanelScroll: StoryFn = () => { }); }; - const onAnimationEnd = () => { + const onAnimationEnd = (): void => { setIsAnimating(false); scrollToChatEnd(); }; - const onAnimationStart = () => { + const onAnimationStart = (): void => { setIsAnimating(true); }; @@ -239,8 +238,8 @@ export const SidePanelScroll: StoryFn = () => { }, [isAnimating]); // eslint-disable-next-line storybook/prefer-pascal-case - const createNewMessage = (message: any, forceBot?: boolean): Omit => { - const messageDirection = forceBot ? "bot" : getRandomInt(2) === 1 ? "user" : "bot"; + const createNewMessage = (newMessage: any, forceBot?: boolean): Omit => { + const messageDirection = getRandomInt(2) === 1 && !forceBot ? "user" : "bot"; return { variant: messageDirection, @@ -248,11 +247,11 @@ export const SidePanelScroll: StoryFn = () => { messageDirection === "user" ? ( Gibby Radki - {message} + {newMessage} ) : ( - + ), }; @@ -263,7 +262,7 @@ export const SidePanelScroll: StoryFn = () => { push(createNewMessage(message)); }; - const pushLargeBotMessage = () => { + const pushLargeBotMessage = (): void => { push( createNewMessage( <> diff --git a/packages/paste-core/components/ai-chat-log/type-docs.json b/packages/paste-core/components/ai-chat-log/type-docs.json index 31ec9b4886..972b106312 100644 --- a/packages/paste-core/components/ai-chat-log/type-docs.json +++ b/packages/paste-core/components/ai-chat-log/type-docs.json @@ -3695,10 +3695,11 @@ "externalProp": true }, "onAnimationEnd": { - "type": "AnimationEventHandler", - "defaultValue": null, + "type": "() => void", + "defaultValue": false, "required": false, - "externalProp": true + "externalProp": false, + "description": "A callback when the animation is complete" }, "onAnimationEndCapture": { "type": "AnimationEventHandler", @@ -3719,10 +3720,11 @@ "externalProp": true }, "onAnimationStart": { - "type": "AnimationEventHandler", - "defaultValue": null, + "type": "() => void", + "defaultValue": false, "required": false, - "externalProp": true + "externalProp": false, + "description": "A callback when the animation is started" }, "onAnimationStartCapture": { "type": "AnimationEventHandler", From d62b979651c0145f110c1c7437b36dec40bcd088 Mon Sep 17 00:00:00 2001 From: Kristian Antrobus Date: Thu, 16 Jan 2025 11:30:30 -0600 Subject: [PATCH 13/13] feat(ai-chat-log): story for user cancel scroll to end --- .../stories/scrollableSidePanel.stories.tsx | 18 +++++++++++++----- .../component-examples/AIChatLogExamples.ts | 15 ++++++++++++--- 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/packages/paste-core/components/ai-chat-log/stories/scrollableSidePanel.stories.tsx b/packages/paste-core/components/ai-chat-log/stories/scrollableSidePanel.stories.tsx index 7bb4d0f742..32f5cbda26 100644 --- a/packages/paste-core/components/ai-chat-log/stories/scrollableSidePanel.stories.tsx +++ b/packages/paste-core/components/ai-chat-log/stories/scrollableSidePanel.stories.tsx @@ -195,8 +195,9 @@ export const SidePanelScroll: StoryFn = () => { ); const [message, setMessage] = React.useState(""); const [mounted, setMounted] = React.useState(false); - const loggerRef = React.useRef(null); - const scrollerRef = React.useRef(null); + const [userInterctedScroll, setUserInteractedScroll] = React.useState(false); + const loggerRef = React.useRef(null); + const scrollerRef = React.useRef(null); React.useEffect(() => { setMounted(true); @@ -222,20 +223,27 @@ export const SidePanelScroll: StoryFn = () => { const onAnimationEnd = (): void => { setIsAnimating(false); - scrollToChatEnd(); + setUserInteractedScroll(false); }; const onAnimationStart = (): void => { + setUserInteractedScroll(false); setIsAnimating(true); }; + const userScrolled = (): void => setUserInteractedScroll(true); + React.useEffect(() => { - const interval = setInterval(() => isAnimating && scrollToChatEnd(), 30); + scrollerRef.current?.addEventListener("wheel", userScrolled); + scrollerRef.current?.addEventListener("touchmove", userScrolled); + const interval = setInterval(() => isAnimating && !userInterctedScroll && scrollToChatEnd(), 5); return () => { if (interval) clearInterval(interval); + scrollerRef.current?.removeEventListener("wheel", userScrolled); + scrollerRef.current?.removeEventListener("touchmove", userScrolled); }; - }, [isAnimating]); + }, [isAnimating, userInterctedScroll]); // eslint-disable-next-line storybook/prefer-pascal-case const createNewMessage = (newMessage: any, forceBot?: boolean): Omit => { diff --git a/packages/paste-website/src/component-examples/AIChatLogExamples.ts b/packages/paste-website/src/component-examples/AIChatLogExamples.ts index 70b0e0e971..c6c68d2baa 100644 --- a/packages/paste-website/src/component-examples/AIChatLogExamples.ts +++ b/packages/paste-website/src/component-examples/AIChatLogExamples.ts @@ -770,6 +770,7 @@ const exampleAIResponseText = const AnimatedBotScrollable = () => { const [isAnimating, setIsAnimating] = React.useState(false); + const [userInterctedScroll, setUserInteractedScroll] = React.useState(false); const loggerRef = React.useRef(null); const scrollerRef = React.useRef(null); @@ -789,22 +790,30 @@ const AnimatedBotScrollable = () => { scrollPosition?.scrollTo({ top: scrollHeight.scrollHeight, behavior: "smooth" }); }; + const userScrolled = () => setUserInteractedScroll(true); + const onAnimationEnd = () => { setIsAnimating(false); - scrollToChatEnd(); + setUserInteractedScroll(false); }; const onAnimationStart = () => { + setUserInteractedScroll(false); setIsAnimating(true); }; React.useEffect(() => { - const interval = setInterval(() => isAnimating && scrollToChatEnd(), 30); + scrollerRef.current?.addEventListener("wheel", userScrolled); + scrollerRef.current?.addEventListener("touchmove", userScrolled); + + const interval = setInterval(() => isAnimating && !userInterctedScroll && scrollToChatEnd(), 5); return () => { if (interval) clearInterval(interval); + scrollerRef.current?.removeEventListener("wheel", userScrolled); + scrollerRef.current?.removeEventListener("touchmove", userScrolled); }; - }, [isAnimating]); + }, [isAnimating, userInterctedScroll, scrollerRef]); const pushLargeBotMessage = () => { push({