Allow re-sizing of document sidebar + make central chat smaller on small screens (#832)

This commit is contained in:
Chris Weaver 2023-12-17 18:17:43 -08:00 committed by GitHub
parent a099f8e296
commit c7a91b1819
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 402 additions and 205 deletions

View File

@ -35,6 +35,8 @@ import { buildFilters } from "@/lib/search/utils";
import { QA, SearchTypeSelector } from "./modifiers/SearchTypeSelector";
import { SelectedDocuments } from "./modifiers/SelectedDocuments";
import { usePopup } from "@/components/admin/connectors/Popup";
import { ResizableSection } from "@/components/resizable/ResizableSection";
import { DanswerInitializingLoader } from "@/components/DanswerInitializingLoader";
const MAX_INPUT_HEIGHT = 200;
@ -45,6 +47,7 @@ export const Chat = ({
availableSources,
availableDocumentSets,
availablePersonas,
documentSidebarInitialWidth,
shouldhideBeforeScroll,
}: {
existingChatSessionId: number | null;
@ -53,6 +56,7 @@ export const Chat = ({
availableSources: ValidSources[];
availableDocumentSets: DocumentSet[];
availablePersonas: Persona[];
documentSidebarInitialWidth?: number;
shouldhideBeforeScroll?: boolean;
}) => {
const router = useRouter();
@ -113,7 +117,6 @@ export const Chat = ({
});
// scroll to bottom initially
console.log(shouldhideBeforeScroll);
const [hasPerformedInitialScroll, setHasPerformedInitialScroll] = useState(
shouldhideBeforeScroll !== true
);
@ -140,6 +143,35 @@ export const Chat = ({
}
}, [message]);
// used for resizing of the document sidebar
const masterFlexboxRef = useRef<HTMLDivElement>(null);
const [maxDocumentSidebarWidth, setMaxDocumentSidebarWidth] = useState<
number | null
>(null);
const adjustDocumentSidebarWidth = () => {
if (masterFlexboxRef.current && document.documentElement.clientWidth) {
// numbers below are based on the actual width the center section for different
// screen sizes. `1700` corresponds to the custom "3xl" tailwind breakpoint
// NOTE: some buffer is needed to account for scroll bars
setMaxDocumentSidebarWidth(
masterFlexboxRef.current.clientWidth -
(document.documentElement.clientWidth > 1700 ? 950 : 810)
);
}
};
useEffect(() => {
adjustDocumentSidebarWidth(); // Adjust the width on initial render
window.addEventListener("resize", adjustDocumentSidebarWidth); // Add resize event listener
return () => {
window.removeEventListener("resize", adjustDocumentSidebarWidth); // Cleanup the event listener
};
}, []);
if (!documentSidebarInitialWidth && maxDocumentSidebarWidth) {
documentSidebarInitialWidth = Math.min(700, maxDocumentSidebarWidth);
}
const onSubmit = async (messageOverride?: string) => {
let currChatSessionId: number;
let isNewSession = chatSessionId === null;
@ -301,7 +333,7 @@ export const Chat = ({
};
return (
<div className="flex w-full overflow-x-hidden">
<div className="flex w-full overflow-x-hidden" ref={masterFlexboxRef}>
{popup}
{currentFeedback && (
<FeedbackModal
@ -314,154 +346,156 @@ export const Chat = ({
/>
)}
<div className="w-full sm:relative">
<div
className="w-full h-screen flex flex-col overflow-y-auto relative"
ref={scrollableDivRef}
>
{selectedPersona && (
<div className="sticky top-0 left-80 z-10 w-full bg-background/90">
<div className="ml-2 p-1 rounded mt-2 w-fit">
<ChatPersonaSelector
personas={availablePersonas}
selectedPersonaId={selectedPersona?.id}
onPersonaChange={(persona) => {
if (persona) {
setSelectedPersona(persona);
}
}}
/>
</div>
</div>
)}
{messageHistory.length === 0 && !isStreaming && (
<div className="flex justify-center items-center h-full">
<div>
<div className="flex">
<div className="mx-auto h-[80px] w-[80px]">
<Image
src="/logo.png"
alt="Logo"
width="1419"
height="1520"
/>
</div>
</div>
<div className="text-2xl font-bold text-strong p-4">
What are you looking for today?
</div>
</div>
</div>
)}
<div
className={
"mt-4 pt-12 sm:pt-0 mx-8" +
(hasPerformedInitialScroll ? "" : " invisible")
}
>
{messageHistory.map((message, i) => {
if (message.type === "user") {
return (
<div key={i}>
<HumanMessage content={message.message} />
</div>
);
} else if (message.type === "assistant") {
const isShowingRetrieved =
(selectedMessageForDocDisplay !== null &&
selectedMessageForDocDisplay === message.messageId) ||
(selectedMessageForDocDisplay === -1 &&
i === messageHistory.length - 1);
return (
<div key={i}>
<AIMessage
messageId={message.messageId}
content={message.message}
query={messageHistory[i]?.query || undefined}
citedDocuments={getCitedDocumentsFromMessage(message)}
isComplete={
i !== messageHistory.length - 1 || !isStreaming
}
hasDocs={
(message.documents && message.documents.length > 0) ===
true
}
handleFeedback={
i === messageHistory.length - 1 && isStreaming
? undefined
: (feedbackType) =>
setCurrentFeedback([
feedbackType,
message.messageId as number,
])
}
isCurrentlyShowingRetrieved={isShowingRetrieved}
handleShowRetrieved={(messageNumber) => {
if (isShowingRetrieved) {
setSelectedMessageForDocDisplay(null);
} else {
if (messageNumber !== null) {
setSelectedMessageForDocDisplay(messageNumber);
} else {
setSelectedMessageForDocDisplay(-1);
}
{documentSidebarInitialWidth !== undefined ? (
<>
<div className="w-full sm:relative">
<div
className="w-full h-screen flex flex-col overflow-y-auto relative"
ref={scrollableDivRef}
>
{selectedPersona && (
<div className="sticky top-0 left-80 z-10 w-full bg-background/90">
<div className="ml-2 p-1 rounded mt-2 w-fit">
<ChatPersonaSelector
personas={availablePersonas}
selectedPersonaId={selectedPersona?.id}
onPersonaChange={(persona) => {
if (persona) {
setSelectedPersona(persona);
}
}}
/>
</div>
);
} else {
return (
<div key={i}>
<AIMessage
messageId={message.messageId}
content={
<p className="text-red-700 text-sm my-auto">
{message.message}
</p>
}
/>
</div>
);
}
})}
{isStreaming &&
messageHistory.length &&
messageHistory[messageHistory.length - 1].type === "user" && (
<div key={messageHistory.length}>
<AIMessage
messageId={null}
content={
<div className="text-sm my-auto">
<ThreeDots
height="30"
width="50"
color="#3b82f6"
ariaLabel="grid-loading"
radius="12.5"
wrapperStyle={{}}
wrapperClass=""
visible={true}
/>
</div>
}
/>
</div>
)}
{/* Some padding at the bottom so the search bar has space at the bottom to not cover the last message*/}
<div className={`min-h-[200px] w-full`}></div>
{messageHistory.length === 0 && !isStreaming && (
<div className="flex justify-center items-center h-full">
<div className="px-8 w-searchbar-small 3xl:w-searchbar">
<div className="flex">
<div className="mx-auto h-[80px] w-[80px]">
<Image
src="/logo.png"
alt="Logo"
width="1419"
height="1520"
/>
</div>
</div>
<div className="mx-auto text-2xl font-bold text-strong p-4 w-fit">
What are you looking for today?
</div>
</div>
</div>
)}
<div ref={endDivRef} />
</div>
</div>
<div
className={
"mt-4 pt-12 sm:pt-0 mx-8" +
(hasPerformedInitialScroll ? "" : " invisible")
}
>
{messageHistory.map((message, i) => {
if (message.type === "user") {
return (
<div key={i}>
<HumanMessage content={message.message} />
</div>
);
} else if (message.type === "assistant") {
const isShowingRetrieved =
(selectedMessageForDocDisplay !== null &&
selectedMessageForDocDisplay === message.messageId) ||
(selectedMessageForDocDisplay === -1 &&
i === messageHistory.length - 1);
return (
<div key={i}>
<AIMessage
messageId={message.messageId}
content={message.message}
query={messageHistory[i]?.query || undefined}
citedDocuments={getCitedDocumentsFromMessage(message)}
isComplete={
i !== messageHistory.length - 1 || !isStreaming
}
hasDocs={
(message.documents &&
message.documents.length > 0) === true
}
handleFeedback={
i === messageHistory.length - 1 && isStreaming
? undefined
: (feedbackType) =>
setCurrentFeedback([
feedbackType,
message.messageId as number,
])
}
isCurrentlyShowingRetrieved={isShowingRetrieved}
handleShowRetrieved={(messageNumber) => {
if (isShowingRetrieved) {
setSelectedMessageForDocDisplay(null);
} else {
if (messageNumber !== null) {
setSelectedMessageForDocDisplay(messageNumber);
} else {
setSelectedMessageForDocDisplay(-1);
}
}
}}
/>
</div>
);
} else {
return (
<div key={i}>
<AIMessage
messageId={message.messageId}
content={
<p className="text-red-700 text-sm my-auto">
{message.message}
</p>
}
/>
</div>
);
}
})}
<div className="absolute bottom-0 z-10 w-full bg-background border-t border-border">
<div className="w-full pb-4 pt-2">
{/* {(isStreaming || messageHistory.length > 0) && (
{isStreaming &&
messageHistory.length &&
messageHistory[messageHistory.length - 1].type === "user" && (
<div key={messageHistory.length}>
<AIMessage
messageId={null}
content={
<div className="text-sm my-auto">
<ThreeDots
height="30"
width="50"
color="#3b82f6"
ariaLabel="grid-loading"
radius="12.5"
wrapperStyle={{}}
wrapperClass=""
visible={true}
/>
</div>
}
/>
</div>
)}
{/* Some padding at the bottom so the search bar has space at the bottom to not cover the last message*/}
<div className={`min-h-[200px] w-full`}></div>
<div ref={endDivRef} />
</div>
</div>
<div className="absolute bottom-0 z-10 w-full bg-background border-t border-border">
<div className="w-full pb-4 pt-2">
{/* {(isStreaming || messageHistory.length > 0) && (
<div className="flex justify-center w-full">
<div className="w-[800px] flex">
<div className="cursor-pointer flex w-fit p-2 rounded border border-neutral-400 text-sm hover:bg-neutral-200 ml-auto mr-4">
@ -491,33 +525,35 @@ export const Chat = ({
</div>
)} */}
<div className="flex">
<div className="w-searchbar mx-auto px-4 pt-1 flex">
<div className="mr-3">
<SearchTypeSelector
selectedSearchType={selectedSearchType}
setSelectedSearchType={setSelectedSearchType}
/>
<div className="flex">
<div className="w-searchbar-small 3xl:w-searchbar mx-auto px-4 pt-1 flex">
<div className="mr-3">
<SearchTypeSelector
selectedSearchType={selectedSearchType}
setSelectedSearchType={setSelectedSearchType}
/>
</div>
{selectedDocuments.length > 0 ? (
<SelectedDocuments
selectedDocuments={selectedDocuments}
/>
) : (
<ChatFilters
{...filterManager}
existingSources={availableSources}
availableDocumentSets={availableDocumentSets}
/>
)}
</div>
</div>
{selectedDocuments.length > 0 ? (
<SelectedDocuments selectedDocuments={selectedDocuments} />
) : (
<ChatFilters
{...filterManager}
existingSources={availableSources}
availableDocumentSets={availableDocumentSets}
/>
)}
</div>
</div>
<div className="flex justify-center py-2 max-w-screen-lg mx-auto mb-2">
<div className="w-full shrink relative px-4 w-searchbar mx-auto">
<textarea
ref={textareaRef}
autoFocus
className={`
<div className="flex justify-center py-2 max-w-screen-lg mx-auto mb-2">
<div className="w-full shrink relative px-4 w-searchbar-small 3xl:w-searchbar mx-auto">
<textarea
ref={textareaRef}
autoFocus
className={`
opacity-100
w-full
shrink
@ -542,42 +578,59 @@ export const Chat = ({
overscroll-contain
resize-none
`}
style={{ scrollbarWidth: "thin" }}
role="textarea"
aria-multiline
placeholder="Ask me anything..."
value={message}
onChange={(e) => setMessage(e.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) {
onSubmit();
event.preventDefault();
}
}}
suppressContentEditableWarning={true}
/>
<div className="absolute bottom-4 right-10">
<div className={"cursor-pointer"} onClick={() => onSubmit()}>
<FiSend
size={18}
className={
"text-emphasis w-9 h-9 p-2 rounded-lg " +
(message ? "bg-blue-200" : "")
}
style={{ scrollbarWidth: "thin" }}
role="textarea"
aria-multiline
placeholder="Ask me anything..."
value={message}
onChange={(e) => setMessage(e.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) {
onSubmit();
event.preventDefault();
}
}}
suppressContentEditableWarning={true}
/>
<div className="absolute bottom-4 right-10">
<div
className={"cursor-pointer"}
onClick={() => onSubmit()}
>
<FiSend
size={18}
className={
"text-emphasis w-9 h-9 p-2 rounded-lg " +
(message ? "bg-blue-200" : "")
}
/>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<DocumentSidebar
selectedMessage={aiMessage}
selectedDocuments={selectedDocuments}
setSelectedDocuments={setSelectedDocuments}
/>
<ResizableSection
intialWidth={documentSidebarInitialWidth}
minWidth={400}
maxWidth={maxDocumentSidebarWidth || undefined}
>
<DocumentSidebar
selectedMessage={aiMessage}
selectedDocuments={selectedDocuments}
setSelectedDocuments={setSelectedDocuments}
/>
</ResizableSection>
</>
) : (
<div className="mx-auto h-full flex flex-col">
<div className="my-auto">
<DanswerInitializingLoader />
</div>
</div>
)}
</div>
);
};

View File

@ -15,6 +15,8 @@ import { Persona } from "../admin/personas/interfaces";
import { InstantSSRAutoRefresh } from "@/components/SSRAutoRefresh";
import { WelcomeModal } from "@/components/WelcomeModal";
import { ApiKeyModal } from "@/components/openai/ApiKeyModal";
import { cookies } from "next/headers";
import { DOCUMENT_SIDEBAR_WIDTH_COOKIE_NAME } from "@/components/resizable/contants";
export default async function ChatPage({
chatId,
@ -175,6 +177,13 @@ export default async function ChatPage({
);
}
const documentSidebarCookieInitialWidth = cookies().get(
DOCUMENT_SIDEBAR_WIDTH_COOKIE_NAME
);
const finalDocumentSidebarInitialWidth = documentSidebarCookieInitialWidth
? parseInt(documentSidebarCookieInitialWidth.value)
: undefined;
return (
<>
<InstantSSRAutoRefresh />
@ -196,6 +205,7 @@ export default async function ChatPage({
availableSources={availableSources}
availableDocumentSets={documentSets}
availablePersonas={personas}
documentSidebarInitialWidth={finalDocumentSidebarInitialWidth}
shouldhideBeforeScroll={shouldhideBeforeScroll}
/>
</div>

View File

@ -45,13 +45,11 @@ export function DocumentSidebar({
<div
className={`
flex-initial
w-document-sidebar
border-l
border-border
overflow-y-hidden
flex
flex-col
pt-4
w-full
`}
id="document-sidebar"
>
@ -117,7 +115,7 @@ export function DocumentSidebar({
</div>
</div>
) : (
<div className="ml-4">
<div className="ml-4 mr-3">
<Text>
When you run ask a question, the retrieved documents will show up
here!

View File

@ -52,7 +52,7 @@ export const AIMessage = ({
const [copyClicked, setCopyClicked] = useState(false);
return (
<div className={"py-5 px-5 flex -mr-6 w-full"}>
<div className="mx-auto w-searchbar relative">
<div className="mx-auto w-searchbar-small 3xl:w-searchbar relative">
<div className="ml-8">
<div className="flex">
<div className="p-1 bg-ai rounded-lg h-fit my-auto">
@ -67,7 +67,7 @@ export const AIMessage = ({
hasDocs &&
handleShowRetrieved !== undefined &&
isCurrentlyShowingRetrieved !== undefined && (
<div className="flex w-message-default absolute ml-8">
<div className="flex w-message-small 3xl:w-message-default absolute ml-8">
<div className="ml-auto">
<ShowHideDocsButton
messageId={messageId}
@ -79,7 +79,7 @@ export const AIMessage = ({
)}
</div>
<div className="w-message-default break-words mt-1 ml-8">
<div className="w-message-small 3xl:w-message-default break-words mt-1 ml-8">
{query !== undefined &&
handleShowRetrieved !== undefined &&
isCurrentlyShowingRetrieved !== undefined && (
@ -194,7 +194,7 @@ export const HumanMessage = ({
}) => {
return (
<div className="py-5 px-5 flex -mr-6 w-full">
<div className="mx-auto w-searchbar">
<div className="mx-auto w-searchbar-small 3xl:w-searchbar">
<div className="ml-8">
<div className="flex">
<div className="p-1 bg-user rounded-lg h-fit">
@ -205,8 +205,8 @@ export const HumanMessage = ({
<div className="font-bold text-emphasis ml-2 my-auto">You</div>
</div>
<div className="mx-auto mt-1 ml-8 w-full w-message-default flex flex-wrap">
<div className="w-full sm:w-full w-message-default break-words">
<div className="mx-auto mt-1 ml-8 w-message-small 3xl:w-message-default flex flex-wrap">
<div className="w-full sm:w-full w-message-small 3xl:w-message-default break-words">
{typeof content === "string" ? (
<ReactMarkdown
className="prose max-w-full"

View File

@ -53,6 +53,7 @@ export function ChatSessionDisplay({
className="flex my-1"
key={chatSession.id}
href={`/chat/${chatSession.id}`}
scroll={false}
>
<BasicSelectable fullWidth selected={isSelected}>
<div className="flex">

View File

@ -0,0 +1,13 @@
import { Bold } from "@tremor/react";
import Image from "next/image";
export function DanswerInitializingLoader() {
return (
<div className="mx-auto animate-pulse">
<div className="h-24 w-24 mx-auto mb-3">
<Image src="/logo.png" alt="Logo" width="1419" height="1520" />
</div>
<Bold>Initializing Danswer</Bold>
</div>
);
}

View File

@ -0,0 +1,116 @@
"use client";
import React, { useEffect, useState } from "react";
import Cookies from "js-cookie";
import { DOCUMENT_SIDEBAR_WIDTH_COOKIE_NAME } from "./contants";
function applyMinAndMax(
width: number,
minWidth: number | undefined,
maxWidth: number | undefined
) {
let newWidth = width;
if (minWidth) {
newWidth = Math.max(width, minWidth); // Ensure the width doesn't go below a minimum value
}
if (maxWidth) {
newWidth = Math.min(newWidth, maxWidth); // Ensure the width doesn't exceed a maximum value
}
return newWidth;
}
export function ResizableSection({
children,
intialWidth,
minWidth,
maxWidth,
}: {
children: JSX.Element;
intialWidth: number;
minWidth: number;
maxWidth?: number;
}) {
const [width, setWidth] = useState<number>(intialWidth);
const [isResizing, setIsResizing] = useState<boolean>(false);
useEffect(() => {
const newWidth = applyMinAndMax(width, minWidth, maxWidth);
setWidth(newWidth);
Cookies.set(DOCUMENT_SIDEBAR_WIDTH_COOKIE_NAME, newWidth.toString(), {
path: "/",
});
}, [minWidth, maxWidth]);
const startResizing = (mouseDownEvent: React.MouseEvent<HTMLDivElement>) => {
setIsResizing(true);
// Disable text selection
document.body.style.userSelect = "none";
document.body.style.cursor = "col-resize";
// Record the initial position of the mouse
const startX = mouseDownEvent.clientX;
const handleMouseMove = (mouseMoveEvent: MouseEvent) => {
// Calculate the change in position
const delta = mouseMoveEvent.clientX - startX;
let newWidth = applyMinAndMax(width - delta, minWidth, maxWidth);
setWidth(newWidth);
Cookies.set(DOCUMENT_SIDEBAR_WIDTH_COOKIE_NAME, newWidth.toString(), {
path: "/",
});
};
const stopResizing = () => {
// Re-enable text selection
document.body.style.userSelect = "";
document.body.style.cursor = "";
// Remove the event listeners
window.removeEventListener("mousemove", handleMouseMove);
window.removeEventListener("mouseup", stopResizing);
setIsResizing(false);
};
window.addEventListener("mousemove", handleMouseMove);
window.addEventListener("mouseup", stopResizing);
};
return (
<div className="flex h-full">
<div
className={`
-mr-1
pr-1
z-40
h-full
`}
>
<div
onMouseDown={startResizing}
className={`
cursor-col-resize
border-l
border-border
h-full
w-full
transition-all duration-300 ease-in hover:border-border-strong hover:border-l-2
${
isResizing
? "transition-all duration-300 ease-in border-border-strong border-l-2"
: ""
}
`}
></div>
</div>
<div
style={{ width: `${width}px` }}
className={`resize-section h-full flex`}
>
{children}
</div>
</div>
);
}
export default ResizableSection;

View File

@ -0,0 +1 @@
export const DOCUMENT_SIDEBAR_WIDTH_COOKIE_NAME = "documentSidebarWidth";

View File

@ -16,11 +16,16 @@ module.exports = {
transparent: "transparent",
current: "currentColor",
extend: {
screens: {
"3xl": "1700px",
},
fontFamily: {
sans: ["var(--font-inter)"],
},
width: {
"message-small": "600px",
"message-default": "740px",
"searchbar-small": "710px",
searchbar: "850px",
"document-sidebar": "800px",
"document-sidebar-large": "1000px",