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

feat: chat details modal #580

Merged
merged 15 commits into from
Dec 21, 2023
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
12 changes: 7 additions & 5 deletions apps/browser/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,9 @@ function Dojo({ params }: { params: { id?: string } }) {

const { mutateAsync: createChat } = useCreateChat();
const { mutateAsync: updateChatTitle } = useUpdateChatTitle();
const { logs, isConnected, isStarting, isRunning, handleStart } = useEvoService(
const { logs, isConnected, isStarting, isRunning, handleStart, status } = useEvoService(
chatId,
isAuthenticated
isAuthenticated,
);

const workspaceUploadUpdate = useWorkspaceUploadUpdate();
Expand All @@ -67,7 +67,7 @@ function Dojo({ params }: { params: { id?: string } }) {
// reset workspace and user files on chatId change
setWorkspace(undefined);
setWorkspaceFiles([]);
}
};

const handleCreateNewChat = async () => {
const id = uuid();
Expand Down Expand Up @@ -110,7 +110,7 @@ function Dojo({ params }: { params: { id?: string } }) {

setNewGoalSubmitted({
goal,
chatId: goalChatId
chatId: goalChatId,
});
};

Expand Down Expand Up @@ -139,7 +139,8 @@ function Dojo({ params }: { params: { id?: string } }) {
// Set isChatLoading to true when evoService is connected
// and the current chatId matches the current goal (if present)
useEffect(() => {
const chatIdMatches = !newGoalSubmitted || chatId === newGoalSubmitted.chatId;
const chatIdMatches =
!newGoalSubmitted || chatId === newGoalSubmitted.chatId;
if (isChatLoading && isConnected && chatIdMatches) {
setIsChatLoading(false);
}
Expand Down Expand Up @@ -186,6 +187,7 @@ function Dojo({ params }: { params: { id?: string } }) {
workspaceUploadUpdate(workspace, uploads);
}
}}
status={status}
/>
) : (
<div className="flex h-full w-full items-center justify-center">
Expand Down
5 changes: 4 additions & 1 deletion apps/browser/components/Chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export interface ChatLog {
content?: string;
user: string;
color?: string;
created_at?: string
}

export interface ChatProps {
Expand All @@ -34,6 +35,7 @@ export interface ChatProps {
isRunning: boolean;
onGoalSubmit: (goal: string) => Promise<void>;
onUpload: (upload: InMemoryFile[]) => void;
status: string | undefined;
}

const Chat: React.FC<ChatProps> = ({
Expand All @@ -42,6 +44,7 @@ const Chat: React.FC<ChatProps> = ({
isRunning,
onGoalSubmit,
onUpload,
status
}: ChatProps) => {
const [{ id: chatId, name: chatName }] = useAtom(chatInfoAtom);
const [showDisclaimer, setShowDisclaimer] = useAtom(showDisclaimerAtom);
Expand Down Expand Up @@ -87,7 +90,7 @@ const Chat: React.FC<ChatProps> = ({
{shouldShowExamplePrompts ? (
<Logo wordmark={false} className="mb-16 w-16" />
) : (
<ChatLogs chatName={chatName ?? "New Session"} isRunning={isStarting || isRunning} logs={logs} />
<ChatLogs status={status} chatName={chatName ?? "New Session"} isRunning={isStarting || isRunning} logs={logs} />
)}

<div
Expand Down
164 changes: 116 additions & 48 deletions apps/browser/components/ChatLogs.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,32 @@
import { ChatLog } from "@/components/Chat";
import React, { useState, useEffect, useRef, useCallback } from "react";
import React, {
useState,
useEffect,
useRef,
useCallback,
useMemo,
} from "react";
import ReactMarkdown from "react-markdown";
import LoadingCircle from "./LoadingCircle";
import Avatar from "./Avatar";
import { ArrowSquareRight } from "@phosphor-icons/react";
import Logo from "./Logo";
import { useSession } from "next-auth/react";
import ChatDetails from "./modals/ChatDetails";
import { sanitizeLogs } from "@/lib/utils/sanitizeLogsDetails";

export interface ChatLogsProps {
logs: ChatLog[];
isRunning: boolean;
chatName: string
status: string | undefined;
chatName: string;
}

export default function ChatLogs(props: ChatLogsProps) {
const { logs, isRunning, chatName } = props;
export default function ChatLogs({
logs,
isRunning,
status,
chatName
}: ChatLogsProps) {
const listContainerRef = useRef<HTMLDivElement | null>(null);
const [isAtBottom, setIsAtBottom] = useState(true);
const { data: session } = useSession();
Expand All @@ -24,6 +37,15 @@ export default function ChatLogs(props: ChatLogsProps) {
});
};

const sanitizedLogs = useMemo(() => {
return sanitizeLogs(logs);
}, [logs]);

const [logsDetails, setLogsDetails] = useState<{
open: boolean;
index: number;
}>({ open: false, index: 0 });

const handleScroll = useCallback(() => {
// Detect if the user is at the bottom of the list
const container = listContainerRef.current;
Expand Down Expand Up @@ -56,10 +78,6 @@ export default function ChatLogs(props: ChatLogsProps) {
};
}, [handleScroll]);

if (isAtBottom) {
scrollToBottom();
}

return (
<>
<div className="flex h-20 items-center justify-center border-b-2 border-zinc-800 md:h-12">
Expand All @@ -70,58 +88,108 @@ export default function ChatLogs(props: ChatLogsProps) {
onScroll={handleScroll}
className="w-full flex-1 items-center space-y-6 overflow-y-auto overflow-x-clip px-2 py-3 text-left [scrollbar-gutter:stable]"
>
{logs.map((msg, index, logs) => {
const isEvo = msg.user === "evo";
const previousMessage = logs[index - 1];
{sanitizedLogs.map((msg, index) => {
return (
<div
key={index}
className="m-auto w-full max-w-[56rem] self-center"
className={"m-auto w-full max-w-[56rem] self-center"}
>
<div className="group relative flex w-full animate-slide-down items-start space-x-3 rounded-lg p-2 text-white opacity-0 transition-colors duration-300 ">
<div className="!h-8 !w-8 !min-w-[2rem]">
{isEvo && previousMessage?.user === "user" ? (
<Logo wordmark={false} className="w-full" chatAvatar />
) : !isEvo ? (
<>
{session?.user.image && session?.user.email ? (
<img
src={session?.user.image}
className="h-full w-full rounded-full bg-cyan-600"
/>
) : !session?.user.email ? (
<Avatar size={32} />
) : (
<div className="w-full rounded-full bg-cyan-600" />
)}
</>
) : null}
</div>
<div className="max-w-[calc(100vw-84px)] space-y-2 pt-1 md:max-w-[49rem]">
{index === 0 || previousMessage?.user !== msg.user ? (
<div className="SenderName font-medium">
{session?.user.name && !msg.user.includes("evo")
? session?.user.name
: msg.user.charAt(0).toUpperCase() + msg.user.slice(1)}
<div className="group relative flex w-full animate-slide-down items-start space-x-3 rounded-lg p-2 pb-10 text-white opacity-0 transition-colors duration-300 ">
<>
{session?.user.image && session?.user.email ? (
<img
src={session?.user.image}
className="h-8 w-8 rounded-full bg-yellow-500"
/>
) : (
<div className="h-8 w-8 rounded-full bg-yellow-500" />
)}
</>
<div className="w-full max-w-[calc(100vw-84px)] space-y-2 pt-1 md:max-w-[49rem]">
<>
<div className="flex items-center justify-between">
<span className="SenderName font-medium">
{session?.user.name ? session?.user.name : "User"}
</span>
</div>
) : null}
</>
<div className="prose prose-invert w-full max-w-none">
<ReactMarkdown>{msg.title.toString()}</ReactMarkdown>
<ReactMarkdown>
{msg.content?.toString() ?? ""}
</ReactMarkdown>
{msg.userMessage}
</div>
</div>
</div>
<div
key={index}
className={"m-auto w-full max-w-[56rem] self-center"}
>
<div className="group relative flex w-full animate-slide-down items-start space-x-3 rounded-lg p-2 pb-10 text-white opacity-0 transition-colors duration-300 ">
<Logo wordmark={false} className="!w-8 !min-w-[2rem]" />
<div className="w-full max-w-[calc(100vw-84px)] space-y-2 pt-1 md:max-w-[49rem]">
<>
<div className="flex items-center justify-between">
<span className="SenderName font-medium">Evo</span>
{!isRunning && (
<button
className="group/button flex items-center space-x-2 text-cyan-500 hover:text-cyan-400"
onClick={() =>
setLogsDetails({
open: true,
index,
})
}
>
<div className="font-regular text-xs group-hover/button:underline">
View Details
</div>
<ArrowSquareRight size={24} />
</button>
)}
</div>
</>
{msg.evoMessage && (
<ReactMarkdown className="prose prose-invert w-full max-w-none">
{msg.evoMessage}
</ReactMarkdown>
)}
{!msg.evoMessage &&
isRunning &&
sanitizedLogs.length - 1 === index && (
<>
<div className="flex items-center space-x-2 text-cyan-500">
<LoadingCircle />
<div className="group/loading flex cursor-pointer items-center space-x-2 text-cyan-500 transition-all duration-500 hover:text-cyan-700">
<div
className="group-hover/loading:underline"
onClick={() =>
setLogsDetails({
open: true,
index,
})
}
>
{status}
</div>
</div>
</div>
</>
)}
{!msg.evoMessage && !isRunning && (
<ReactMarkdown className="prose prose-invert w-full max-w-none">
It seems I have been interrupted, please try again.
</ReactMarkdown>
)}
</div>
{isEvo && isRunning && index === logs.length - 1 && (
<div className="flex items-center space-x-2 text-cyan-500">
<LoadingCircle />
</div>
)}
</div>
</div>
</div>
);
})}
</div>
<ChatDetails
isOpen={logsDetails.open}
onClose={() => setLogsDetails({ ...logsDetails, open: false })}
logs={sanitizedLogs[logsDetails.index]}
/>
</>
);
}
100 changes: 100 additions & 0 deletions apps/browser/components/modals/ChatDetails.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { useRef, useState } from "react";
import Modal from "./ModalBase";
import ReactMarkdown from "react-markdown";
import { CaretUp } from "@phosphor-icons/react";
import { MessageSet } from "@/lib/utils/sanitizeLogsDetails";
import clsx from "clsx";

interface ChatDetailsProps {
isOpen: boolean;
onClose: () => void;
logs: MessageSet;
}

export default function ChatDetails({
isOpen,
onClose,
logs,
}: ChatDetailsProps) {
const [expandedStep, setExpandedStep] = useState<string | null>(null);
const contentRefs = useRef<{ [index: number]: HTMLDivElement | null }>({});

const toggleStep = (step: string, index: number) => {
const currentEl = contentRefs.current[index];
if (!currentEl) return;

if (expandedStep !== null && expandedStep !== step) {
const currentIndex = Object.keys(logs.details).indexOf(expandedStep);
const currentExpandedEl = contentRefs.current[currentIndex];
if (currentExpandedEl) currentExpandedEl.style.height = "0px";
}

if (expandedStep === step) {
currentEl.style.height = "0px";
setExpandedStep(null);
} else {
currentEl.style.height = `${currentEl.scrollHeight}px`;
setExpandedStep(step);
}
};

return (
<Modal
isOpen={isOpen}
onClose={() => {
setExpandedStep(null);
onClose();
}}
title="Details"
>
<div className="space-y-4">
{logs &&
Object.entries(logs.details).map(
([stepTitle, stepDetails], index) => (
<div
key={stepTitle}
className={clsx(
"prose-condensed prose prose-zinc prose-invert rounded-md bg-zinc-800 shadow-md transition-colors duration-0 ease-in-out hover:shadow-lg",
{
"cursor-pointer duration-150 hover:bg-zinc-700":
expandedStep !== stepTitle,
}
)}
>
<button
onClick={() => toggleStep(stepTitle, index)}
className="group flex w-full items-center justify-between p-4"
>
<ReactMarkdown>{stepTitle}</ReactMarkdown>
{stepDetails.length > 0 && (
<CaretUp
weight="bold"
size={14}
className={clsx(
"transform text-white transition-transform duration-500 ease-in-out group-hover:text-cyan-500",
expandedStep !== stepTitle && "rotate-180"
)}
/>
)}
</button>
<div
ref={(el) => {
contentRefs.current[index] = el;
}}
className={clsx(
"step h-0 overflow-hidden transition-[height] duration-500 ease-in-out"
)}
>
{stepDetails.map((detail, detailIndex) => (
<div className="p-4 pt-0" key={detailIndex}>
<ReactMarkdown>{detail}</ReactMarkdown>
</div>
))}
</div>
</div>
)
)}
</div>
</Modal>
);
}
Loading
Loading