Skip to content

Commit c1006b3

Browse files
authored
feat(assistant): assistant improvements (#4214)
* chore(assistant): only write to slack in prod * fix(assistant): fix the vercel error sometimes showing * feat(assistant): use animations and composer disabled states * chore(ci): lint and format * chore(ci): biome * docs(ai-chat-log): doucment how to animate markdown * docs(ai-chat-log): doucment how to animate markdown * fix(assistant): consistent font sizes * fix(assistant): paragraph font size inherit * feat(assistant): refactor assistant markdown components * docs(ai-chat-log): update line number
1 parent 50da077 commit c1006b3

File tree

11 files changed

+260
-183
lines changed

11 files changed

+260
-183
lines changed

packages/paste-website/src/components/assistant/AssistantCanvas.tsx

Lines changed: 51 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,12 @@ type AssistantCanvasProps = {
1818

1919
export const AssistantCanvas: React.FC<AssistantCanvasProps> = ({ selectedThreadID }) => {
2020
const [mounted, setMounted] = React.useState(false);
21+
const [isAnimating, setIsAnimating] = React.useState(false);
22+
const [userInterctedScroll, setUserInteractedScroll] = React.useState(false);
23+
2124
const messages = useAssistantMessagesStore(useShallow((state) => state.messages));
2225
const setMessages = useAssistantMessagesStore(useShallow((state) => state.setMessages));
23-
const activeRun = useAssistantRunStore(useShallow((state) => state.activeRun));
26+
const { activeRun, lastActiveRun, clearLastActiveRun } = useAssistantRunStore(useShallow((state) => state));
2427
const isCreatingAResponse = useIsMutating({ mutationKey: ["create-assistant-run"] });
2528

2629
const memoedMessages = React.useMemo(() => messages, [messages]);
@@ -50,14 +53,46 @@ export const AssistantCanvas: React.FC<AssistantCanvasProps> = ({ selectedThread
5053
setMounted(true);
5154
}, []);
5255

56+
const scrollToChatEnd = (): void => {
57+
const scrollPosition: any = scrollerRef.current;
58+
const scrollHeight: any = loggerRef.current;
59+
scrollPosition?.scrollTo({ top: scrollHeight.scrollHeight, behavior: "smooth" });
60+
};
61+
5362
// scroll to bottom of chat log when new messages are added
5463
React.useEffect(() => {
5564
if (!mounted || !loggerRef.current) return;
56-
scrollerRef.current?.scrollTo({ top: loggerRef.current.scrollHeight, behavior: "smooth" });
65+
scrollToChatEnd();
5766
}, [memoedMessages, mounted]);
5867

68+
const onAnimationEnd = (): void => {
69+
setIsAnimating(false);
70+
setUserInteractedScroll(false);
71+
// avoid reanimating the same message
72+
clearLastActiveRun();
73+
};
74+
75+
const onAnimationStart = (): void => {
76+
setUserInteractedScroll(false);
77+
setIsAnimating(true);
78+
};
79+
80+
const userScrolled = (): void => setUserInteractedScroll(true);
81+
82+
React.useEffect(() => {
83+
scrollerRef.current?.addEventListener("wheel", userScrolled);
84+
scrollerRef.current?.addEventListener("touchmove", userScrolled);
85+
86+
const interval = setInterval(() => isAnimating && !userInterctedScroll && scrollToChatEnd(), 5);
87+
return () => {
88+
if (interval) clearInterval(interval);
89+
scrollerRef.current?.removeEventListener("wheel", userScrolled);
90+
scrollerRef.current?.removeEventListener("touchmove", userScrolled);
91+
};
92+
}, [isAnimating, userInterctedScroll]);
93+
5994
return (
60-
<Box ref={scrollerRef} tabIndex={0} overflowY="auto">
95+
<Box ref={scrollerRef} tabIndex={0} overflowY="auto" paddingX="space60">
6196
<Box maxWidth="1000px" marginX="auto">
6297
{activeRun != null && <AssistantMessagePoller />}
6398
<AIChatLog ref={loggerRef}>
@@ -94,11 +129,21 @@ export const AssistantCanvas: React.FC<AssistantCanvasProps> = ({ selectedThread
94129
Your conversations are not used to train OpenAI&apos;s models, but are stored by OpenAI.
95130
</Text>
96131
</Box>
97-
{messages?.map((threadMessage): React.ReactNode => {
132+
{messages?.map((threadMessage, index): React.ReactNode => {
98133
if (threadMessage.role === "assistant") {
99-
return <AssistantMessage key={threadMessage.id} threadMessage={threadMessage} />;
134+
return (
135+
<AssistantMessage
136+
key={threadMessage.id}
137+
threadMessage={threadMessage}
138+
// Only animate the last message recieved from AI and must be most recent run to avoid reanimating
139+
animated={index === messages.length - 1 && lastActiveRun?.id === threadMessage.run_id}
140+
size="fullScreen"
141+
onAnimationEnd={onAnimationEnd}
142+
onAnimationStart={onAnimationStart}
143+
/>
144+
);
100145
}
101-
return <UserMessage key={threadMessage.id} threadMessage={threadMessage} />;
146+
return <UserMessage key={threadMessage.id} threadMessage={threadMessage} size="fullScreen" />;
102147
})}
103148
{(isCreatingAResponse || activeRun != null) && <LoadingMessage />}
104149
</AIChatLog>

packages/paste-website/src/components/assistant/AssistantComposer.tsx

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { useIsMutating } from "@tanstack/react-query";
12
import { Button } from "@twilio-paste/button";
23
import { ChatComposer, ChatComposerActionGroup, ChatComposerContainer } from "@twilio-paste/chat-composer";
34
import { SendIcon } from "@twilio-paste/icons/esm/SendIcon";
@@ -9,7 +10,9 @@ import {
910
type LexicalEditor,
1011
} from "@twilio-paste/lexical-library";
1112
import * as React from "react";
13+
import { useShallow } from "zustand/react/shallow";
1214

15+
import { useAssistantRunStore } from "../../stores/assistantRunStore";
1316
import { useAssistantThreadsStore } from "../../stores/assistantThreadsStore";
1417
import useStoreWithLocalStorage from "../../stores/useStore";
1518
import { EnterKeySubmitPlugin } from "./EnterKeySubmitPlugin";
@@ -20,9 +23,12 @@ export const AssistantComposer: React.FC<{ onMessageCreation: (message: string,
2023
const [message, setMessage] = React.useState("");
2124
const threadsStore = useStoreWithLocalStorage(useAssistantThreadsStore, (state) => state);
2225
const selectedThread = threadsStore?.selectedThreadID;
23-
26+
const { activeRun } = useAssistantRunStore(useShallow((state) => state));
27+
const isCreatingAResponse = useIsMutating({ mutationKey: ["create-assistant-run"] });
2428
const editorInstanceRef = React.useRef<LexicalEditor>(null);
2529

30+
const isLoading = Boolean(isCreatingAResponse || activeRun != null);
31+
2632
const handleComposerChange = (editorState: EditorState): void => {
2733
editorState.read(() => {
2834
const text = $getRoot().getTextContent();
@@ -49,21 +55,25 @@ export const AssistantComposer: React.FC<{ onMessageCreation: (message: string,
4955
throw error;
5056
},
5157
}}
58+
disabled={isLoading}
5259
ariaLabel="Message"
5360
placeholder="Type here..."
5461
onChange={handleComposerChange}
5562
editorInstanceRef={editorInstanceRef}
5663
>
5764
<ClearEditorPlugin />
58-
<EnterKeySubmitPlugin onKeyDown={submitMessage} />
65+
<EnterKeySubmitPlugin onKeyDown={() => !isLoading && submitMessage()} />
5966
</ChatComposer>
6067
<ChatComposerActionGroup>
6168
<Button
6269
variant="primary_icon"
6370
size="reset"
71+
disabled={isLoading}
6472
onClick={() => {
65-
submitMessage();
66-
editorInstanceRef.current?.dispatchCommand(CLEAR_EDITOR_COMMAND, undefined);
73+
if (!isLoading) {
74+
submitMessage();
75+
editorInstanceRef.current?.dispatchCommand(CLEAR_EDITOR_COMMAND, undefined);
76+
}
6777
}}
6878
>
6979
<SendIcon decorative={false} title="Send" />

packages/paste-website/src/components/assistant/AssistantLayout.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import React from "react";
33

44
const Window: React.FC<React.PropsWithChildren> = ({ children }) => {
55
return (
6-
<Box display="grid" gridTemplateColumns="400px 1fr" height="100svh" width="100%">
6+
<Box display="grid" gridTemplateColumns="300px 1fr" height="100svh" width="100%">
77
{children}
88
</Box>
99
);

packages/paste-website/src/components/assistant/AssistantMarkdown.tsx

Lines changed: 79 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -3,37 +3,39 @@ import { Box } from "@twilio-paste/box";
33
import { CodeBlock, CodeBlockHeader, type CodeBlockProps, CodeBlockWrapper } from "@twilio-paste/code-block";
44
import { Heading } from "@twilio-paste/heading";
55
import { InlineCode } from "@twilio-paste/inline-code";
6-
import { ListItem, OrderedList, UnorderedList } from "@twilio-paste/list";
7-
import { Separator } from "@twilio-paste/separator";
86
import { TBody, THead, Table, Td, Th, Tr } from "@twilio-paste/table";
9-
import Markdown from "markdown-to-jsx";
7+
import Markdown, { MarkdownToJSX } from "markdown-to-jsx";
108
import * as React from "react";
119

12-
export const AssistantHeading: React.FC<React.PropsWithChildren> = ({ children }) => {
10+
export const AssistantHeading1: React.FC<React.PropsWithChildren> = ({ children }) => {
1311
return (
14-
<Heading as="h2" variant="heading40">
12+
<Heading as="h1" variant="heading10">
1513
{children}
1614
</Heading>
1715
);
1816
};
19-
export const AssistantParagraph: React.FC<React.PropsWithChildren> = ({ children }) => {
17+
export const AssistantHeading2: React.FC<React.PropsWithChildren> = ({ children }) => {
2018
return (
21-
<Box
22-
as="p"
23-
color="inherit"
24-
fontSize="fontSize30"
25-
fontWeight="fontWeightNormal"
26-
lineHeight="lineHeight30"
27-
marginTop="space0"
28-
marginBottom="space50"
29-
>
19+
<Heading as="h2" variant="heading20">
3020
{children}
31-
</Box>
21+
</Heading>
3222
);
3323
};
34-
export const AssistantSeparator: React.FC = () => {
35-
return <Separator orientation="horizontal" verticalSpacing="space50" />;
24+
export const AssistantHeading3: React.FC<React.PropsWithChildren> = ({ children }) => {
25+
return (
26+
<Heading as="h3" variant="heading30">
27+
{children}
28+
</Heading>
29+
);
30+
};
31+
export const AssistantHeading4: React.FC<React.PropsWithChildren> = ({ children }) => {
32+
return (
33+
<Heading as="h4" variant="heading40">
34+
{children}
35+
</Heading>
36+
);
3637
};
38+
3739
export const AssistantTable: React.FC<React.PropsWithChildren> = ({ children }) => {
3840
return (
3941
<Box marginBottom="space50">
@@ -43,84 +45,64 @@ export const AssistantTable: React.FC<React.PropsWithChildren> = ({ children })
4345
);
4446
};
4547

46-
export const AssistantMarkdown: React.FC<{ children: string }> = ({ children }) => {
47-
return (
48-
<Markdown
49-
options={{
50-
renderRule(next, node) {
51-
if (node.type === "3") {
52-
return (
53-
<Box marginBottom="space50">
54-
<CodeBlockWrapper>
55-
<CodeBlockHeader>{node.lang ? node.lang : "javascript"}</CodeBlockHeader>
56-
<CodeBlock
57-
code={String.raw`${node.text}`}
58-
maxLines={10}
59-
language={node.lang ? (node.lang as CodeBlockProps["language"]) : "javascript"}
60-
/>
61-
</CodeBlockWrapper>
62-
</Box>
63-
);
64-
}
48+
export const assistantMarkdownOptions = {
49+
renderRule(next: () => React.ReactChild, node: MarkdownToJSX.ParserResult) {
50+
if (node.type === "3") {
51+
return (
52+
<Box marginBottom="space50">
53+
<CodeBlockWrapper>
54+
<CodeBlockHeader>{node.lang ? node.lang : "javascript"}</CodeBlockHeader>
55+
<CodeBlock
56+
code={String.raw`${node.text}`}
57+
maxLines={10}
58+
language={node.lang ? (node.lang as CodeBlockProps["language"]) : "javascript"}
59+
/>
60+
</CodeBlockWrapper>
61+
</Box>
62+
);
63+
}
64+
return next();
65+
},
66+
overrides: {
67+
code: {
68+
component: InlineCode,
69+
},
70+
a: {
71+
component: Anchor,
72+
},
73+
table: {
74+
component: AssistantTable,
75+
},
76+
thead: {
77+
component: THead,
78+
},
79+
tbody: {
80+
component: TBody,
81+
},
82+
tr: {
83+
component: Tr,
84+
},
85+
td: {
86+
component: Td,
87+
},
88+
th: {
89+
component: Th,
90+
},
91+
h1: {
92+
component: AssistantHeading1,
93+
},
94+
h2: {
95+
component: AssistantHeading2,
96+
},
97+
h3: {
98+
component: AssistantHeading3,
99+
},
100+
h4: {
101+
component: AssistantHeading4,
102+
},
103+
},
104+
};
65105

66-
return next();
67-
},
68-
overrides: {
69-
code: {
70-
component: InlineCode,
71-
},
72-
a: {
73-
component: Anchor,
74-
},
75-
h1: {
76-
component: AssistantHeading,
77-
},
78-
h2: {
79-
component: AssistantHeading,
80-
},
81-
h3: {
82-
component: AssistantHeading,
83-
},
84-
h4: {
85-
component: AssistantHeading,
86-
},
87-
p: {
88-
component: AssistantParagraph,
89-
},
90-
ol: {
91-
component: OrderedList,
92-
},
93-
ul: {
94-
component: UnorderedList,
95-
},
96-
li: {
97-
component: ListItem,
98-
},
99-
hr: {
100-
component: AssistantSeparator,
101-
},
102-
table: {
103-
component: AssistantTable,
104-
},
105-
thead: {
106-
component: THead,
107-
},
108-
tbody: {
109-
component: TBody,
110-
},
111-
tr: {
112-
component: Tr,
113-
},
114-
td: {
115-
component: Td,
116-
},
117-
th: {
118-
component: Th,
119-
},
120-
},
121-
}}
122-
>
123-
{children}
124-
</Markdown>
125-
);
106+
export const AssistantMarkdown: React.FC<{ children: string }> = ({ children }) => {
107+
return <Markdown options={assistantMarkdownOptions}>{children}</Markdown>;
126108
};

packages/paste-website/src/components/assistant/AssistantMessage.tsx

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,30 @@
1-
import { AIChatMessage, AIChatMessageAuthor, AIChatMessageBody } from "@twilio-paste/ai-chat-log";
1+
import {
2+
AIChatMessage,
3+
AIChatMessageAuthor,
4+
AIChatMessageBody,
5+
AIChatMessageBodyProps,
6+
} from "@twilio-paste/ai-chat-log";
7+
import { compiler } from "markdown-to-jsx";
28
import { type Message } from "openai/resources/beta/threads/messages";
39
import * as React from "react";
410

511
import { formatTimestamp } from "../../utils/formatTimestamp";
6-
import { AssistantMarkdown } from "./AssistantMarkdown";
12+
import { assistantMarkdownOptions } from "./AssistantMarkdown";
713

8-
export const AssistantMessage: React.FC<{ threadMessage: Message }> = ({ threadMessage }) => {
14+
interface AssistantMessageProps extends AIChatMessageBodyProps {
15+
threadMessage: Message;
16+
}
17+
18+
export const AssistantMessage: React.FC<AssistantMessageProps> = ({ threadMessage, ...props }) => {
919
return (
1020
<AIChatMessage variant="bot">
1121
<AIChatMessageAuthor aria-label={`said by paste assistant at ${formatTimestamp(threadMessage.created_at)}`}>
1222
PasteBot
1323
</AIChatMessageAuthor>
14-
<AIChatMessageBody>
15-
{threadMessage.content[0].type === "text" && (
16-
<AssistantMarkdown key={threadMessage.id}>{threadMessage.content[0].text.value}</AssistantMarkdown>
17-
)}
24+
<AIChatMessageBody {...props}>
25+
{threadMessage.content.length > 0 &&
26+
threadMessage.content[0]?.type === "text" &&
27+
compiler(threadMessage.content[0].text.value, assistantMarkdownOptions)}
1828
</AIChatMessageBody>
1929
</AIChatMessage>
2030
);

0 commit comments

Comments
 (0)