Skip to content

Commit

Permalink
Touchpoint QA round 2
Browse files Browse the repository at this point in the history
  • Loading branch information
peterszerzo committed Jan 9, 2025
1 parent debf0b2 commit 2fec351
Show file tree
Hide file tree
Showing 4 changed files with 158 additions and 131 deletions.
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

0 comments on commit 2fec351

Please sign in to comment.