Skip to content
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

Touchpoint QA round 2 #174

Merged
merged 1 commit into from
Jan 10, 2025
Merged
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions packages/touchpoint-ui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,7 @@ const App = forwardRef<AppRef, Props>((props, ref) => {
<>
<ChatMessages
responses={responses}
colorMode={colorMode}
handler={handler}
uploadedFiles={uploadedFiles}
className={
Expand Down
33 changes: 21 additions & 12 deletions packages/touchpoint-ui/src/components/ChatInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { IconButton } from "./ui/IconButton";
import { ArrowForward, Attachment, Delete, Check, Error } from "./ui/Icons";
import { type ChoiceMessage } from "../types";
import { MessageChoices } from "./ChatMessages";
import { useTailwindMediaQuery } from "../hooks";

interface ChatInputProps {
className?: string;
Expand Down Expand Up @@ -47,14 +48,21 @@ const ChatInput: FC<ChatInputProps> = ({
const [uploadErrorMessage, setUploadErrorMessage] = useState<string | null>(
null,
);
const [fileInfo, setFileInfo] = useState<FileInfo | null>(null);
const [uploadedFileInfo, setUploadedFileInfo] = useState<FileInfo | null>(
null,
);
const fileInputRef = useRef<HTMLInputElement>(null);

const textInputRef = useRef<HTMLTextAreaElement>(null);

const isMd = useTailwindMediaQuery("md");

// Autofocus input on desktop only
useEffect(() => {
textInputRef.current?.focus();
}, []);
if (isMd) {
textInputRef.current?.focus();
}
}, [isMd]);

const isInputEmpty = useMemo(() => {
return inputValue.trim() === "";
Expand All @@ -64,7 +72,7 @@ const ChatInput: FC<ChatInputProps> = ({
if (isInputEmpty) {
return;
}
if (uploadUrl != null && fileInfo != null) {
if (uploadUrl != null && uploadedFileInfo != null) {
handler.sendStructured({
uploadIds: [uploadUrl.uploadId],
utterance: inputValue,
Expand All @@ -73,7 +81,7 @@ const ChatInput: FC<ChatInputProps> = ({
handler.sendText(inputValue);
}
setInputValue("");
setFileInfo(null);
setUploadedFileInfo(null);
};

const isUploadEnabled = uploadUrl != null;
Expand All @@ -94,7 +102,7 @@ const ChatInput: FC<ChatInputProps> = ({
}

const { name, size, type } = file;
setFileInfo({ name, size, type });
setUploadedFileInfo({ name, size, type });

if (size / 1024 ** 2 > MAX_INPUT_FILE_SIZE_IN_MB) {
setUploadErrorMessage(
Expand Down Expand Up @@ -138,7 +146,7 @@ const ChatInput: FC<ChatInputProps> = ({
<div
className={clsx(
"bg-primary-5 transition-colors duration-200 p-2 rounded-plus text-base font-normal",
isTextAreaInFocus || isUploadEnabled ? "" : "hover:bg-secondary-20",
isTextAreaInFocus ? "" : "hover:bg-secondary-20",
)}
>
{uploadErrorMessage != null && (
Expand All @@ -147,16 +155,16 @@ const ChatInput: FC<ChatInputProps> = ({
<span className="truncate ml-1">{uploadErrorMessage}</span>
</div>
)}
{fileInfo && (
{uploadedFileInfo && (
<>
<div className="flex items-center justify-between mb-2 w-full">
<p className="flex items-center truncate mx-2">
{uploadErrorMessage != null ? (
<Error size={16} className="text-error-primary" />
) : (
<Check size={16} />
<Check size={16} className="text-primary-60" />
)}
<span className="truncate ml-3">{fileInfo.name}</span>
<span className="truncate ml-3">{uploadedFileInfo.name}</span>
</p>
<IconButton
className="flex-none"
Expand All @@ -165,7 +173,7 @@ const ChatInput: FC<ChatInputProps> = ({
onClick={
isUploadEnabled
? () => {
setFileInfo(null);
setUploadedFileInfo(null);
setUploadErrorMessage(null);
if (fileInputRef.current != null) {
fileInputRef.current.value = "";
Expand All @@ -180,7 +188,7 @@ const ChatInput: FC<ChatInputProps> = ({
</>
)}
<div className={clsx("flex items-end")}>
{isUploadEnabled ? (
{isUploadEnabled && uploadedFileInfo == null ? (
<>
<label
htmlFor="file-upload"
Expand All @@ -198,6 +206,7 @@ const ChatInput: FC<ChatInputProps> = ({
/>
</>
) : (
/* Disabled attachment button */
<IconButton
className="flex-none"
Icon={Attachment}
Expand Down
173 changes: 100 additions & 73 deletions packages/touchpoint-ui/src/components/ChatMessages.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/* eslint-disable jsdoc/require-jsdoc */
import { useEffect, useRef, type FC, Fragment } from "react";
import { type FC, Fragment, useEffect, useRef, useState } from "react";
import {
type Response,
type ConversationHandler,
Expand All @@ -11,10 +11,12 @@ import { marked } from "marked";
import { Loader } from "./ui/Loader";
import { TextButton } from "./ui/TextButton";
import { ArrowForward } from "./ui/Icons";
import { type ColorMode } from "../types";

export interface ChatMessagesProps {
handler: ConversationHandler;
responses: Response[];
colorMode: ColorMode;
uploadedFiles: Record<string, File>;
className?: string;
}
Expand Down Expand Up @@ -81,13 +83,16 @@ const UserMessage: FC<{ text: string; files?: File[] }> = ({ text, files }) => {

export const ChatMessages: FC<ChatMessagesProps> = ({
responses,
colorMode,
uploadedFiles,
className,
}) => {
const isWaiting = responses[responses.length - 1]?.type === "user";

const containerRef = useRef<HTMLDivElement | null>(null);

const [scrollAtBottom, setScrollAtBottom] = useState<boolean>(false);

useEffect(() => {
const containerNode = containerRef.current;
if (containerNode == null) {
Expand All @@ -99,86 +104,108 @@ export const ChatMessages: FC<ChatMessagesProps> = ({
}, [responses]);

return (
<div
className={clsx(
"h-full p-2 md:p-3 overflow-y-auto no-scrollbar space-y-8",
className,
<div className={clsx("h-full relative", className)}>
{isWaiting || scrollAtBottom || responses.length < 3 ? null : (
<div
data-theme={colorMode === "dark" ? "light" : "dark"}
className={clsx(
"absolute inset-x-0 h-[1px] top-0 bg-background opacity-[0.01] backdrop-blur-md",
)}
/>
)}
ref={containerRef}
>
{responses.map((response, responseIndex) => {
// User response
if (response.type === "user") {
if (response.payload.type === "text") {
<div
className="h-full p-2 md:p-3 overflow-y-auto no-scrollbar space-y-8"
ref={containerRef}
onScroll={() => {
const containerNode = containerRef.current;
if (containerNode == null) {
return;
}
const isAtBottom =
containerNode.scrollHeight - containerNode.scrollTop ===
containerNode.clientHeight;
if (!isAtBottom && scrollAtBottom) {
setScrollAtBottom(false);
}
if (isAtBottom && !scrollAtBottom) {
setScrollAtBottom(true);
}
}}
>
{responses.map((response, responseIndex) => {
// User response
if (response.type === "user") {
if (response.payload.type === "text") {
return (
<UserMessage key={responseIndex} text={response.payload.text} />
);
} else if (
response.payload.type === "structured" &&
response.payload.utterance != null &&
response.payload.uploadIds != null
) {
return (
<UserMessage
key={responseIndex}
text={response.payload.utterance}
files={response.payload.uploadIds
.map((uploadId) => uploadedFiles[uploadId])
.filter((file) => file != null)}
/>
);
} else {
return null;
}
}
// Failure
if (response.type === "failure") {
return (
<UserMessage key={responseIndex} text={response.payload.text} />
<p key={responseIndex} className="text-error-primary text-base">
{response.payload.text}
</p>
);
} else if (
response.payload.type === "structured" &&
response.payload.utterance != null &&
response.payload.uploadIds != null
) {
return (
<UserMessage
key={responseIndex}
text={response.payload.utterance}
files={response.payload.uploadIds
.map((uploadId) => uploadedFiles[uploadId])
.filter((file) => file != null)}
/>
);
} else {
return null;
}
}
// Failure
if (response.type === "failure") {
// Bot response
const isLast = responseIndex === responses.length - 1;
return (
<p key={responseIndex} className="text-error-primary text-base">
{response.payload.text}
</p>
);
}
// Bot response
const isLast = responseIndex === responses.length - 1;
return (
<Fragment key={responseIndex}>
<div className={clsx("space-y-2", isLast ? "min-h-full" : "")}>
<Fragment key={responseIndex}>
<div className={clsx("space-y-2", isLast ? "min-h-full" : "")}>
{response.payload.messages.map((message, messageIndex) => {
return (
<div key={messageIndex} className="text-base">
<div
className="pr-10"
dangerouslySetInnerHTML={{
__html: marked(message.text),
}}
/>
</div>
);
})}
</div>
{/* Render the selected choice text as a user message */}
{response.payload.messages.map((message, messageIndex) => {
return (
<div key={messageIndex} className="text-base">
<div
className="pr-10"
dangerouslySetInnerHTML={{
__html: marked(message.text),
}}
if (message.selectedChoiceId != null) {
const selectedChoice = message.choices.find(
(choice) => choice.choiceId === message.selectedChoiceId,
);
if (selectedChoice == null) {
return null;
}
return (
<UserMessage
key={messageIndex}
text={selectedChoice.choiceText}
/>
</div>
);
})}
</div>
{/* Render the selected choice text as a user message */}
{response.payload.messages.map((message, messageIndex) => {
if (message.selectedChoiceId != null) {
const selectedChoice = message.choices.find(
(choice) => choice.choiceId === message.selectedChoiceId,
);
if (selectedChoice == null) {
return null;
);
}
return (
<UserMessage
key={messageIndex}
text={selectedChoice.choiceText}
/>
);
}
return null;
})}
</Fragment>
);
})}
{isWaiting ? <Loader label="Thinking" /> : null}
return null;
})}
</Fragment>
);
})}
{isWaiting ? <Loader label="Thinking" /> : null}
</div>
</div>
);
};
Loading
Loading