Skip to content

feat(ai-chat-log): add typewriter animations #4199

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 15 commits into from
Jan 22, 2025
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/tough-moles-film.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@twilio-paste/ai-chat-log": minor
"@twilio-paste/core": minor
---

[AI Chat Log] added optional typewriter animation to AIChatMessageBody
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, BoxStyleProps> = {
default: {
Expand Down Expand Up @@ -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<HTMLDivElement, AIChatMessageBodyProps>(
({ 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 (
<Box
Expand All @@ -55,7 +65,7 @@ export const AIChatMessageBody = React.forwardRef<HTMLDivElement, AIChatMessageB
whiteSpace="pre-wrap"
id={id}
>
{children}
{childrenToRender}
</Box>
);
},
Expand Down
70 changes: 70 additions & 0 deletions packages/paste-core/components/ai-chat-log/src/utils.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import React, { useEffect, useState } from "react";

// Hook to animate text content of React elements
export const useAnimatedText = (children: React.ReactNode, speed = 10): React.ReactNode => {
const [animatedChildren, setAnimatedChildren] = useState<React.ReactNode>();
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 = (nodes: React.ReactNode): number => {
let length = 0;
React.Children.forEach(nodes, (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 = (nodes: React.ReactNode, currentIndex: number): React.ReactNode => {
let currentTextIndex = currentIndex;
return React.Children.map(nodes, (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;
} 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;
}

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));
}
}, [children, textIndex]);

return animatedChildren;
};

export default useAnimatedText;
112 changes: 110 additions & 2 deletions packages/paste-core/components/ai-chat-log/stories/parts.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,22 @@
// 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";
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 {
Expand Down Expand Up @@ -72,7 +80,7 @@ export const AIMessageLoading = (): React.ReactNode => {
return (
<AIChatLog>
<AIChatMessage variant="user">
<AIChatMessageBody>
<AIChatMessageBody animated>
<p>Pssst! The three rows have dynamic widths. Refresh to see it in action!</p>
<AIChatMessageLoading />
</AIChatMessageBody>
Expand Down Expand Up @@ -153,7 +161,7 @@ export const FullAIMessage = (): React.ReactNode => {
<AIChatLog>
<AIChatMessage variant="bot">
<AIChatMessageAuthor aria-label="AI said">Good Bot</AIChatMessageAuthor>
<AIChatMessageBody>
<AIChatMessageBody animated>
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.
Expand Down Expand Up @@ -204,3 +212,103 @@ export const FullAIMessage = (): React.ReactNode => {
</AIChatLog>
);
};

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 (
<Box>
<Heading as="h2" variant="heading20">
With enriched text
</Heading>
<Box marginBottom="space60">
<AIChatMessageBody animated size="fullScreen">
<span style={{ fontWeight: 600 }}>Lorem ipsum dolor, sit amet consectetur adipisicing elit.</span> Deserunt
delectus fuga, necessitatibus eligendiiure adipisci facilis exercitationem officiis dolorem laborum, ex fugiat
quisquam itaque, earum sit <a href="https://google.com">nesciunt impedit repellat assumenda.</a> new text,{" "}
<Anchor showExternal href="https://google.com">
434324
</Anchor>
<UnorderedList>
<ListItem>Item 1</ListItem>
<ListItem>Item 2</ListItem>
<ListItem>Item 3</ListItem>
</UnorderedList>
</AIChatMessageBody>
</Box>
</Box>
);
};

export const MessageBodyTypeWriterComplexComponents = (): React.ReactNode => {
return (
<Box>
<Heading as="h2" variant="heading20">
With complex components
</Heading>
<Box marginBottom="space60">
<AIChatMessageBody animated>
<Paragraph>
<Box display="flex">
This <ThumbsUpIcon decorative />
is text that contains <ThumbsDownIcon decorative /> icons between elements
</Box>
</Paragraph>
<Paragraph>
<Blockquote url="#">
<BlockquoteContent>
With AI-driven products, the design process is no longer just about aesthetics. It’s about designing for
the human experience as a whole.
</BlockquoteContent>
<BlockquoteCitation author="Google" source="People + AI Guidebook" />
</Blockquote>
</Paragraph>
<Paragraph>
<Callout variant="neutral">
<CalloutHeading as="h2">Heads up!</CalloutHeading>
<CalloutText>This is some information you need to know.</CalloutText>
</Callout>
</Paragraph>
<Paragraph>
<CodeBlockWrapper>
<CodeBlockHeader>Build a button</CodeBlockHeader>
<CodeBlock code={rubyCode} language="ruby" />
</CodeBlockWrapper>
</Paragraph>
<Paragraph>
<Disclosure visible>
<DisclosureHeading as="h4" variant="heading40">
Between the World and Me by Ta-Nehisi Coates
</DisclosureHeading>
<DisclosureContent>
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.
</DisclosureContent>
</Disclosure>
</Paragraph>
</AIChatMessageBody>
</Box>
</Box>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ export const UseChatLogger: StoryFn = () => {
<AIChatMessageAuthor aria-label={isBot ? "AI said" : "You said"}>
{isBot ? "Good Bot" : "Gibby Radki"}
</AIChatMessageAuthor>
<AIChatMessageBody>{message}</AIChatMessageBody>
<AIChatMessageBody animated={isBot}>{message}</AIChatMessageBody>
</AIChatMessage>
),
};
Expand Down
7 changes: 7 additions & 0 deletions packages/paste-core/components/ai-chat-log/type-docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
51 changes: 51 additions & 0 deletions packages/paste-website/src/component-examples/AIChatLogExamples.ts
Original file line number Diff line number Diff line change
Expand Up @@ -712,3 +712,54 @@ const SystemError = () => {
render(
<SystemError />
)`.trim();

export const animatedBotWithFeedback = `
const AnimatedMessageWithFeedback = () => {
const [animated, setAnimated] = React.useState(true)

const restart = () => {
setAnimated(false)
setTimeout(() => {
setAnimated(true)
}, 100)
}

return (
<>
<Button variant="secondary" onClick={restart}>
<ResetIcon decorative={false} size="sizeIcon10" title="reset" />{" "}Restart
</Button>
<AIChatLog>
<AIChatMessage variant="bot">
<AIChatMessageAuthor aria-label="AI said">Good Bot</AIChatMessageAuthor>
<AIChatMessageBody animated={animated}>
I found multiple solutions for the issue with your environment variable, <InlineCode>TWILIO_AUTH_TOKEN</InlineCode>. Other helpful resources can be found at <Anchor href="#" showExternal>Twilio API Docs</Anchor>.
</AIChatMessageBody>
<AIChatMessageActionGroup>
<AIChatMessageActionCard aria-label="Feedback form">
Is this helpful?
<Button variant="reset" size="reset" aria-label="this is a helpful response">
<ThumbsUpIcon decorative={false} title="like result" />
</Button>
<Button variant="reset" size="reset">
<ThumbsDownIcon decorative={false} title="dislike result" aria-label="this is not a helpful response" />
</Button>
</AIChatMessageActionCard>
<AIChatMessageActionCard aria-label="Rewrite and copy buttons">
<Button variant="reset" size="reset">
<RefreshIcon decorative={true}/> Rewrite
</Button>
<Button variant="reset" size="reset">
<CopyIcon decorative={true}/> Copy
</Button>
</AIChatMessageActionCard>
</AIChatMessageActionGroup>
</AIChatMessage>
</AIChatLog>
</>
);
};

render(
<AnimatedMessageWithFeedback />
)`.trim();
Loading
Loading