Skip to content

Commit

Permalink
Revise styling.
Browse files Browse the repository at this point in the history
  • Loading branch information
mathewjordan committed Jan 23, 2025
1 parent 5171309 commit 0b5e6c3
Show file tree
Hide file tree
Showing 10 changed files with 318 additions and 231 deletions.
205 changes: 66 additions & 139 deletions components/Chat/Chat.tsx
Original file line number Diff line number Diff line change
@@ -1,163 +1,90 @@
import { AI_DISCLAIMER, AI_SEARCH_UNSUBMITTED } from "@/lib/constants/common";
import React, { useEffect, useState } from "react";
import {
StyledResponseActions,
StyledResponseDisclaimer,
StyledUnsubmitted,
} from "@/components/Chat/Response/Response.styled";
import { defaultState, useSearchState } from "@/context/search-context";

import Announcement from "@/components/Shared/Announcement";
import { Button } from "@nulib/design-system";
import ChatConversation from "./Conversation";
import ChatFeedback from "@/components/Chat/Feedback/Feedback";
import ChatResponse from "@/components/Chat/Response/Response";
import Container from "@/components/Shared/Container";
import { prepareQuestion } from "@/lib/chat-helpers";
import useChatSocket from "@/hooks/useChatSocket";
import useQueryParams from "@/hooks/useQueryParams";
import { v4 as uuidv4 } from "uuid";

const Chat = ({
viewResultsCallback,
}: {
viewResultsCallback?: () => void;
}) => {
const { searchTerm = "" } = useQueryParams();
const { authToken, isConnected, message, sendMessage } = useChatSocket();
const [conversationRef, setConversationRef] = useState<string>();

const [streamingError, setStreamingError] = useState("");
interface Conversation {
question: string;
answer: string;
}

/**
* get the`chat` state and dispatch function from the search context
* for persisting the chat state when search screen tabs are switched
*/
const {
searchState: { chat },
searchDispatch,
} = useSearchState();
const { question, answer } = chat;
const Chat = () => {
const { searchTerm } = useQueryParams();

const [isStreamingComplete, setIsStreamingComplete] = useState(false);
const initialConversation = {
question: searchTerm,
answer: "",
};

useEffect(() => {
if (
!isStreamingComplete &&
isConnected &&
authToken &&
searchTerm &&
conversationRef
) {
resetChat();
const preparedQuestion = prepareQuestion(
searchTerm,
authToken,
conversationRef,
);
sendMessage(preparedQuestion);
}
}, [
authToken,
isStreamingComplete,
isConnected,
searchTerm,
conversationRef,
sendMessage,
const [conversationRef, setConversationRef] = useState<string>();
const [conversation, setConversation] = useState<Conversation[]>([
initialConversation,
]);
const [isStreaming, setIsStreaming] = useState(false);

useEffect(() => {
setIsStreamingComplete(false);
setConversationRef(uuidv4());
const conversationRef = uuidv4();
setIsStreaming(true);
setConversationRef(conversationRef);
setConversation([initialConversation]);
}, [searchTerm]);

useEffect(() => {
if (!message || !conversationRef) return;
}, [message]);

function handleNewQuestion() {
const input = document.getElementById("dc-search") as HTMLInputElement;
if (input) {
input.focus();
input.value = "";
}
}

function resetChat() {
searchDispatch({
chat: defaultState.chat,
type: "updateChat",
});
}

if (!searchTerm)
return (
<Container>
<StyledUnsubmitted>{AI_SEARCH_UNSUBMITTED}</StyledUnsubmitted>
</Container>
);
const handleConversationCallback = (value: string) => {
setConversation([
...conversation,
{
question: value,
answer: "",
},
]);
};

const handleResponseCallback = (content: any) => {
if (!conversationRef) return;

setIsStreamingComplete(true);
searchDispatch({
chat: {
// content here is now a react element
// once continued conversations ar e in place
// see note below for question refactor
answer: content,

// documents should be eventually removed as
// they are now integrated into content
// doing so will require some careful refactoring
// as the documents are used in feedback form
documents: [],

// question should become an entry[] with
// entry[n].question and entry[n].content
question: searchTerm || "",

ref: conversationRef,
},
type: "updateChat",
});
setIsStreaming(false);
};

return (
<>
<ChatResponse
conversationRef={conversationRef}
isStreamingComplete={isStreamingComplete}
key={conversationRef}
message={message}
question={searchTerm}
responseCallback={handleResponseCallback}
/>
{streamingError && (
<Container>
<Announcement css={{ marginTop: "1rem" }}>
{streamingError}
</Announcement>
</Container>
)}
{isStreamingComplete && (
<>
<Container>
<StyledResponseActions>
<Button isPrimary isLowercase onClick={viewResultsCallback}>
View More Results
</Button>
<Button isLowercase onClick={handleNewQuestion}>
Ask Another Question
</Button>
</StyledResponseActions>
<StyledResponseDisclaimer>{AI_DISCLAIMER}</StyledResponseDisclaimer>
</Container>
<ChatFeedback />
</>
)}
</>
<Container>
<section
data-conversation-initial={searchTerm}
data-conversation-length={conversation.length}
data-conversation-ref={conversationRef}
>
{conversation.map((entry, index) => {
return (
<ChatResponse
conversationRef={conversationRef}
key={index}
question={entry.question}
responseCallback={handleResponseCallback}
/>
);
})}
<ChatConversation
conversationCallback={handleConversationCallback}
isStreaming={isStreaming}
/>
{isStreaming && (
<>
{/* <StyledResponseActions>
<Button isPrimary isLowercase onClick={viewResultsCallback}>
View More Results
</Button>
<Button isLowercase onClick={handleNewQuestion}>
Ask Another Question
</Button>
</StyledResponseActions>
<StyledResponseDisclaimer>{AI_DISCLAIMER}</StyledResponseDisclaimer> */}
{/* <ChatFeedback /> */}
</>
)}
</section>
</Container>
);
};

export default React.memo(Chat);
export default Chat;
124 changes: 124 additions & 0 deletions components/Chat/Conversation.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { styled } from "@/stitches.config";
import { useRef } from "react";

const textareaPlaceholder = "Ask a followup question...";

interface ChatConversationProps {
conversationCallback: (message: string) => void;
isStreaming?: boolean;
}

const ChatConversation: React.FC<ChatConversationProps> = ({
conversationCallback,
isStreaming,
}) => {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const formRef = useRef<HTMLFormElement>(null);

const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
submitConversationCallback();
};

const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (isStreaming) return;

if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
submitConversationCallback();
}
};

const submitConversationCallback = () => {
const value = textareaRef.current?.value;
if (value) conversationCallback(value);

/* Clear the textarea and unfocus it */
textareaRef.current!.value = "";
textareaRef.current!.blur();
};

const handleFocus = () => {
const isFocused = String(textareaRef.current === document.activeElement);
formRef.current!.dataset.isFocused = isFocused;
};

return (
<StyledChatConversation>
<form onSubmit={handleSubmit} ref={formRef} data-is-focused="false">
<textarea
ref={textareaRef}
onKeyDown={handleKeyDown}
placeholder={textareaPlaceholder}
onFocus={handleFocus}
onBlur={handleFocus}
></textarea>
<button type="submit" disabled={isStreaming}>
submit
</button>
</form>
</StyledChatConversation>
);
};

const StyledChatConversation = styled("div", {
position: "relative",
zIndex: 0,

form: {
transition: "$dcAll",
borderRadius: "3px",
flexWrap: "wrap",
overflow: "hidden",

["&[data-is-focused=true]"]: {
backgroundColor: "$white !important",
boxShadow: "3px 3px 11px #0001",
outline: "2px solid $purple60",
},

["&[data-is-focused=false]"]: {
backgroundColor: "#f0f0f0",
boxShadow: "none",
outline: "2px solid transparent",

textarea: {
color: "$black50",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
},
},
},

textarea: {
width: "100%",
height: "100%",
padding: "$gr3",
border: "none",
resize: "none",
backgroundColor: "$gray6",
fontSize: "$gr3",
lineHeight: "147%",
zIndex: "1",
fontFamily: "$northwesternSansRegular",
overflow: "hidden",
outline: "none",
transition: "$dcAll",
boxSizing: "border-box",

"&::placeholder": {
overflow: "hidden",
color: "$black50",
textOverflow: "ellipsis",
},
},

button: {
position: "absolute",
bottom: "$gr2",
right: "$gr2",
},
});

export default ChatConversation;
29 changes: 2 additions & 27 deletions components/Chat/Response/Images.tsx
Original file line number Diff line number Diff line change
@@ -1,38 +1,13 @@
import { useEffect, useState } from "react";

import GridItem from "@/components/Grid/Item";
import { StyledImages } from "@/components/Chat/Response/Response.styled";
import { Work } from "@nulib/dcapi-types";

const INITIAL_MAX_ITEMS = 5;

const ResponseImages = ({
isStreamingComplete,
works,
}: {
isStreamingComplete: boolean;
works: Work[];
}) => {
const [nextIndex, setNextIndex] = useState(0);

useEffect(() => {
if (isStreamingComplete) {
setNextIndex(works.length);
return;
}

if (nextIndex < works.length && nextIndex < INITIAL_MAX_ITEMS) {
const timer = setTimeout(() => {
setNextIndex(nextIndex + 1);
}, 100);

return () => clearTimeout(timer);
}
}, [isStreamingComplete, nextIndex, works.length]);

const ResponseImages = ({ works }: { works: Work[] }) => {
return (
<StyledImages>
{works.slice(0, nextIndex).map((document: Work) => (
{works.slice(0, INITIAL_MAX_ITEMS).map((document: Work) => (
<GridItem key={document.id} item={document} />
))}
</StyledImages>
Expand Down
Loading

0 comments on commit 0b5e6c3

Please sign in to comment.