Skip to content

Commit 9c2f827

Browse files
committed
feat(assistant): use animations and composer disabled states
1 parent 8c5203e commit 9c2f827

File tree

8 files changed

+114
-33
lines changed

8 files changed

+114
-33
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
@@ -13,16 +13,22 @@ import * as React from "react";
1313
import { useAssistantThreadsStore } from "../../stores/assistantThreadsStore";
1414
import useStoreWithLocalStorage from "../../stores/useStore";
1515
import { EnterKeySubmitPlugin } from "./EnterKeySubmitPlugin";
16+
import { useAssistantRunStore } from "../../stores/assistantRunStore";
17+
import { useShallow } from "zustand/react/shallow";
18+
import { useIsMutating } from "@tanstack/react-query";
1619

1720
export const AssistantComposer: React.FC<{ onMessageCreation: (message: string, selectedThread?: string) => void }> = ({
1821
onMessageCreation,
1922
}) => {
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 = !!(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: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { AIChatMessageBody } from "@twilio-paste/ai-chat-log";
12
import { Anchor } from "@twilio-paste/anchor";
23
import { Box } from "@twilio-paste/box";
34
import { CodeBlock, CodeBlockHeader, type CodeBlockProps, CodeBlockWrapper } from "@twilio-paste/code-block";
@@ -6,7 +7,7 @@ import { InlineCode } from "@twilio-paste/inline-code";
67
import { ListItem, OrderedList, UnorderedList } from "@twilio-paste/list";
78
import { Separator } from "@twilio-paste/separator";
89
import { TBody, THead, Table, Td, Th, Tr } from "@twilio-paste/table";
9-
import Markdown from "markdown-to-jsx";
10+
import Markdown, { MarkdownToJSX } from "markdown-to-jsx";
1011
import * as React from "react";
1112

1213
export const AssistantHeading: React.FC<React.PropsWithChildren> = ({ children }) => {
@@ -43,11 +44,8 @@ export const AssistantTable: React.FC<React.PropsWithChildren> = ({ children })
4344
);
4445
};
4546

46-
export const AssistantMarkdown: React.FC<{ children: string }> = ({ children }) => {
47-
return (
48-
<Markdown
49-
options={{
50-
renderRule(next, node) {
47+
export const assistantMarkdownOptions = {
48+
renderRule(next: () => React.ReactChild, node: MarkdownToJSX.ParserResult) {
5149
if (node.type === "3") {
5250
return (
5351
<Box marginBottom="space50">
@@ -118,7 +116,13 @@ export const AssistantMarkdown: React.FC<{ children: string }> = ({ children })
118116
component: Th,
119117
},
120118
},
121-
}}
119+
120+
}
121+
122+
export const AssistantMarkdown: React.FC<{ children: string }> = ({ children }) => {
123+
return (
124+
<Markdown
125+
options={assistantMarkdownOptions}
122126
>
123127
{children}
124128
</Markdown>

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";
27
import { type Message } from "openai/resources/beta/threads/messages";
38
import * as React from "react";
9+
import { compiler } from "markdown-to-jsx";
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.length > 0 && 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
);

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

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
1-
import { AIChatMessage, AIChatMessageAuthor, AIChatMessageBody } from "@twilio-paste/ai-chat-log";
1+
import { AIChatMessage, AIChatMessageAuthor, AIChatMessageBody, AIChatMessageBodyProps } from "@twilio-paste/ai-chat-log";
22
import { UserIcon } from "@twilio-paste/icons/esm/UserIcon";
33
import { type Message } from "openai/resources/beta/threads/messages";
44
import * as React from "react";
55

66
import { formatTimestamp } from "../../utils/formatTimestamp";
77
import { AssistantMarkdown } from "./AssistantMarkdown";
88

9-
export const UserMessage: React.FC<{ threadMessage: Message }> = ({ threadMessage }) => {
9+
interface UserMessageProps extends AIChatMessageBodyProps {
10+
threadMessage: Message
11+
}
12+
13+
export const UserMessage: React.FC<UserMessageProps> = ({ threadMessage, ...props }) => {
1014
return (
1115
<AIChatMessage variant="user">
1216
<AIChatMessageAuthor
@@ -15,7 +19,7 @@ export const UserMessage: React.FC<{ threadMessage: Message }> = ({ threadMessag
1519
>
1620
You
1721
</AIChatMessageAuthor>
18-
<AIChatMessageBody>
22+
<AIChatMessageBody {...props}>
1923
{threadMessage.content[0].type === "text" && (
2024
<AssistantMarkdown key={threadMessage.id}>{threadMessage.content[0].text.value}</AssistantMarkdown>
2125
)}

packages/paste-website/src/pages/assistant.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ const Assistant: NextPage = () => {
2424
/>
2525
</Head>
2626
<AssistantPage />
27-
<ReactQueryDevtools initialIsOpen={false} />
27+
<ReactQueryDevtools initialIsOpen={false} buttonPosition="bottom-left" />
2828
</QueryClientProvider>
2929
);
3030
};

packages/paste-website/src/stores/assistantRunStore.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,22 @@ import { type Run } from "openai/resources/beta/threads/runs";
22
import { create } from "zustand";
33
import { devtools } from "zustand/middleware";
44

5-
type State = { activeRun: Run | undefined };
6-
type Actions = { setActiveRun: (newRun: Run | undefined) => void };
5+
type State = { activeRun: Run | undefined; lastActiveRun: Run | undefined };
6+
type Actions = { setActiveRun: (newRun: Run | undefined) => void; clearLastActiveRun: () => void };
77

88
export const useAssistantRunStore = create<State & Actions>()(
99
devtools((set) => ({
1010
activeRun: undefined,
11+
lastActiveRun: undefined,
1112
setActiveRun: (newRun) => {
12-
set(() => ({ activeRun: newRun }));
13+
set((prevState) => ({
14+
...prevState,
15+
activeRun: newRun,
16+
lastActiveRun: !newRun ? prevState.lastActiveRun : newRun,
17+
}));
1318
},
14-
})),
19+
clearLastActiveRun: () => {
20+
set((prevState) => ({ ...prevState, lastActiveRun: undefined }));
21+
},
22+
}))
1523
);

0 commit comments

Comments
 (0)