From 727af41b60deab67d772a1aeb86e8583afcaa09c Mon Sep 17 00:00:00 2001 From: Tat Dat Duong Date: Tue, 4 Mar 2025 14:11:58 +0100 Subject: [PATCH 01/14] Add .prettierrc --- .prettierrc | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 .prettierrc diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..222861c --- /dev/null +++ b/.prettierrc @@ -0,0 +1,4 @@ +{ + "tabWidth": 2, + "useTabs": false +} From d65a74ed4f562c0569232ce52a7d7af2f0e979b2 Mon Sep 17 00:00:00 2001 From: Tat Dat Duong Date: Tue, 4 Mar 2025 14:12:56 +0100 Subject: [PATCH 02/14] feat: add UI tweaks --- src/App.tsx | 6 +----- src/components/thread/index.tsx | 37 ++++++++++++++------------------- 2 files changed, 17 insertions(+), 26 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 2f5c3b5..f1b3264 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,11 +2,7 @@ import "./App.css"; import { Thread } from "@/components/thread"; function App() { - return ( -
- -
- ); + return ; } export default App; diff --git a/src/components/thread/index.tsx b/src/components/thread/index.tsx index 07743c3..f5a3d91 100644 --- a/src/components/thread/index.tsx +++ b/src/components/thread/index.tsx @@ -48,8 +48,8 @@ export function Thread() { const stream = useStreamContext(); const messages = stream.messages; const isLoading = stream.isLoading; - const prevMessageLength = useRef(0); + const prevMessageLength = useRef(0); useEffect(() => { if ( messages.length !== prevMessageLength.current && @@ -75,14 +75,14 @@ export function Thread() { const toolMessages = ensureToolCallsHaveResponses(stream.messages); stream.submit( { messages: [...toolMessages, newHumanMessage] }, - { streamMode: ["values"] } + { streamMode: ["values"] }, ); setInput(""); }; const handleRegenerate = ( - parentCheckpoint: Checkpoint | null | undefined + parentCheckpoint: Checkpoint | null | undefined, ) => { // Do this so the loading state is correct prevMessageLength.current = prevMessageLength.current - 1; @@ -95,15 +95,12 @@ export function Thread() { const chatStarted = isLoading || messages.length > 0; const renderMessages = messages.filter( - (m) => !m.id?.startsWith(DO_NOT_RENDER_ID_PREFIX) + (m) => !m.id?.startsWith(DO_NOT_RENDER_ID_PREFIX), ); return (
{!chatStarted && ( @@ -121,7 +118,7 @@ export function Thread() {
{renderMessages.map((message, index) => @@ -138,7 +135,7 @@ export function Thread() { isLoading={isLoading} handleRegenerate={handleRegenerate} /> - ) + ), )} {isLoading && !firstTokenReceived && }
@@ -146,29 +143,27 @@ export function Thread() {
setInput(e.target.value)} placeholder="Type your message..." - className="p-5 border-[0px] shadow-none ring-0 outline-none focus:outline-none focus:ring-0" + className="px-4 py-6 border-none bg-transparent shadow-none ring-0 outline-none focus:outline-none focus:ring-0" /> - +
+ +
From c9427dfcdcbc4e3d991f4b8e012ebb0028bf5deb Mon Sep 17 00:00:00 2001 From: Tat Dat Duong Date: Tue, 4 Mar 2025 14:19:00 +0100 Subject: [PATCH 03/14] Align human messages to the end --- src/components/thread/messages/human.tsx | 2 +- src/components/thread/utils.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/thread/messages/human.tsx b/src/components/thread/messages/human.tsx index 3e9393b..453631c 100644 --- a/src/components/thread/messages/human.tsx +++ b/src/components/thread/messages/human.tsx @@ -69,7 +69,7 @@ export function HumanMessage({ onSubmit={handleSubmitEdit} /> ) : ( -

{contentString}

+

{contentString}

)}
c.type === "text") From 40ad30ce2f6b1e06df4045fb4fd6233459678275 Mon Sep 17 00:00:00 2001 From: Tat Dat Duong Date: Tue, 4 Mar 2025 14:37:39 +0100 Subject: [PATCH 04/14] Make icons stable --- src/components/thread/index.tsx | 1 + src/components/thread/messages/ai.tsx | 8 +++++++- src/components/thread/messages/human.tsx | 14 +++++++++++--- src/components/thread/messages/shared.tsx | 4 +++- 4 files changed, 22 insertions(+), 5 deletions(-) diff --git a/src/components/thread/index.tsx b/src/components/thread/index.tsx index f5a3d91..72c95cc 100644 --- a/src/components/thread/index.tsx +++ b/src/components/thread/index.tsx @@ -27,6 +27,7 @@ function Title({ className }: { className?: string }) { } function NewThread() { + // eslint-disable-next-line @typescript-eslint/no-unused-vars const [_, setThreadId] = useQueryParam("threadId", StringParam); return ( diff --git a/src/components/thread/messages/ai.tsx b/src/components/thread/messages/ai.tsx index 96a29b0..7680460 100644 --- a/src/components/thread/messages/ai.tsx +++ b/src/components/thread/messages/ai.tsx @@ -5,6 +5,7 @@ import { BranchSwitcher, CommandBar } from "./shared"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { MarkdownText } from "../markdown-text"; import { LoadExternalComponent } from "@langchain/langgraph-sdk/react-ui/client"; +import { cn } from "@/lib/utils"; function CustomComponent({ message, @@ -67,7 +68,12 @@ export function AssistantMessage({ {contentString}
)} -
+
setValue(e.target.value)} onKeyDown={handleKeyDown} + className="focus-visible:ring-0" /> ); } @@ -57,7 +58,7 @@ export function HumanMessage({ return (
@@ -69,9 +70,16 @@ export function HumanMessage({ onSubmit={handleSubmitEdit} /> ) : ( -

{contentString}

+

{contentString}

)} -
+ +
handleCopy(e)} + onClick={(e) => handleCopy(e)} variant="ghost" tooltip="Copy content" disabled={disabled} @@ -82,6 +82,7 @@ export function BranchSwitcher({
+ ); +} + export function Thread() { const [input, setInput] = useState(""); const [firstTokenReceived, setFirstTokenReceived] = useState(false); @@ -112,72 +135,96 @@ export function Thread() { ); return ( -
-
- {!chatStarted && ( -
- - </div> - )} - {chatStarted && ( - <div className="hidden md:flex items-center gap-3 absolute top-4 right-4"> - <NewThread /> - <Title /> - </div> - )} - - <div - className={cn( - "flex flex-col gap-4 max-w-4xl w-full mx-auto mt-12 overflow-y-auto", - !chatStarted && "hidden", - )} - > - {renderMessages.map((message, index) => - message.type === "human" ? ( - <HumanMessage - key={"id" in message ? message.id : `${message.type}-${index}`} - message={message} - isLoading={isLoading} - /> - ) : ( - <AssistantMessage - key={"id" in message ? message.id : `${message.type}-${index}`} - message={message} - isLoading={isLoading} - handleRegenerate={handleRegenerate} - /> - ), - )} - {isLoading && !firstTokenReceived && <AssistantMessageLoading />} - </div> - </div> - + <div className="flex flex-col w-full h-screen overflow-hidden"> <div className={cn( - "bg-background rounded-2xl border shadow-md mx-auto w-full max-w-4xl", - chatStarted && "fixed bottom-6 left-0 right-0", + "grow grid grid-rows-[auto_1fr]", + !chatStarted && "grid-rows-[1fr]", )} > - <form - onSubmit={handleSubmit} - className="grid grid-rows-[1fr,auto] gap-2 max-w-4xl mx-auto" - > - <Input - type="text" - value={input} - onChange={(e) => setInput(e.target.value)} - placeholder="Type your message..." - className="px-4 py-6 border-none bg-transparent shadow-none ring-0 outline-none focus:outline-none focus:ring-0" - /> + {chatStarted && ( + <div className="flex items-center justify-between gap-3 p-2 pl-4 z-10 relative"> + <Title /> + <NewThread /> - <div className="flex items-center justify-end p-2 pt-0"> - <Button type="submit" disabled={isLoading || !input.trim()}> - Send - </Button> + <div className="absolute inset-x-0 top-full h-5 bg-gradient-to-b from-background to-background/0" /> </div> - </form> + )} + + <StickToBottom className="relative"> + <StickyToBottomContent + className={cn( + "absolute inset-0", + !chatStarted && "flex flex-col items-stretch mt-[25vh]", + chatStarted && "grid grid-rows-[1fr_auto]", + )} + contentClassName="pt-8 pb-16 px-4 max-w-4xl mx-auto flex flex-col gap-4 w-full empty:hidden" + content={ + <> + {renderMessages.map((message, index) => + message.type === "human" ? ( + <HumanMessage + key={ + "id" in message + ? message.id + : `${message.type}-${index}` + } + message={message} + isLoading={isLoading} + /> + ) : ( + <AssistantMessage + key={ + "id" in message + ? message.id + : `${message.type}-${index}` + } + message={message} + isLoading={isLoading} + handleRegenerate={handleRegenerate} + /> + ), + )} + {isLoading && !firstTokenReceived && ( + <AssistantMessageLoading /> + )} + </> + } + footer={ + <div className="sticky flex flex-col items-center gap-8 bottom-8 px-4"> + {!chatStarted && <Title />} + <div + className={cn( + "bg-background rounded-2xl border shadow-md mx-auto w-full max-w-4xl", + // chatStarted && "fixed bottom-6 inset-x-0", + )} + > + <form + onSubmit={handleSubmit} + className="grid grid-rows-[1fr_auto] gap-2 max-w-4xl mx-auto" + > + <Input + type="text" + value={input} + onChange={(e) => setInput(e.target.value)} + placeholder="Type your message..." + className="px-4 py-6 border-none bg-transparent shadow-none ring-0 outline-none focus:outline-none focus:ring-0" + /> + + <div className="flex items-center justify-end p-2 pt-0"> + <Button + type="submit" + disabled={isLoading || !input.trim()} + > + Send + </Button> + </div> + </form> + </div> + </div> + } + /> + </StickToBottom> </div> </div> ); From 702147d3a82d7ca1c3c27eaef0a27ca20b000a93 Mon Sep 17 00:00:00 2001 From: Tat Dat Duong <david@duong.cz> Date: Tue, 4 Mar 2025 15:43:35 +0100 Subject: [PATCH 09/14] Make the logo larger --- src/components/icons/langgraph.tsx | 11 ++++++++++- src/components/thread/index.tsx | 29 ++++++++++++----------------- 2 files changed, 22 insertions(+), 18 deletions(-) diff --git a/src/components/icons/langgraph.tsx b/src/components/icons/langgraph.tsx index 826927b..4bac592 100644 --- a/src/components/icons/langgraph.tsx +++ b/src/components/icons/langgraph.tsx @@ -1,4 +1,12 @@ -export function LangGraphLogoSVG({ width = 20, height = 20 }) { +export function LangGraphLogoSVG({ + className, + width, + height, +}: { + width?: number; + height?: number; + className?: string; +}) { return ( <svg width={width} @@ -6,6 +14,7 @@ export function LangGraphLogoSVG({ width = 20, height = 20 }) { viewBox="0 0 98 51" fill="none" xmlns="http://www.w3.org/2000/svg" + className={className} > <path fillRule="evenodd" diff --git a/src/components/thread/index.tsx b/src/components/thread/index.tsx index e301c49..d3368da 100644 --- a/src/components/thread/index.tsx +++ b/src/components/thread/index.tsx @@ -18,15 +18,6 @@ import { SquarePen } from "lucide-react"; import { StringParam, useQueryParam } from "use-query-params"; import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom"; -function Title({ className }: { className?: string }) { - return ( - <div className={cn("flex gap-2 items-center", className)}> - <LangGraphLogoSVG width={32} height={32} /> - <h1 className="text-xl font-medium">LangGraph Chat</h1> - </div> - ); -} - function NewThread() { const [threadId, setThreadId] = useQueryParam("threadId", StringParam); if (!threadId) return null; @@ -144,7 +135,11 @@ export function Thread() { > {chatStarted && ( <div className="flex items-center justify-between gap-3 p-2 pl-4 z-10 relative"> - <Title /> + <div className="flex gap-2 items-center"> + <LangGraphLogoSVG width={32} height={32} /> + <h1 className="text-xl font-medium">LangGraph Chat</h1> + </div> + <NewThread /> <div className="absolute inset-x-0 top-full h-5 bg-gradient-to-b from-background to-background/0" /> @@ -192,13 +187,13 @@ export function Thread() { } footer={ <div className="sticky flex flex-col items-center gap-8 bottom-8 px-4"> - {!chatStarted && <Title />} - <div - className={cn( - "bg-background rounded-2xl border shadow-md mx-auto w-full max-w-4xl", - // chatStarted && "fixed bottom-6 inset-x-0", - )} - > + {!chatStarted && ( + <div className="flex gap-3 items-center"> + <LangGraphLogoSVG className="flex-shrink-0 h-8" /> + <h1 className="text-2xl font-medium">LangGraph Chat</h1> + </div> + )} + <div className="bg-background rounded-2xl border shadow-md mx-auto w-full max-w-4xl"> <form onSubmit={handleSubmit} className="grid grid-rows-[1fr_auto] gap-2 max-w-4xl mx-auto" From c4e4cc20c53e8030e0f1231448ddece81413a7ed Mon Sep 17 00:00:00 2001 From: Tat Dat Duong <david@duong.cz> Date: Tue, 4 Mar 2025 15:46:40 +0100 Subject: [PATCH 10/14] Fix ghost message typing indicator --- src/components/thread/index.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/thread/index.tsx b/src/components/thread/index.tsx index d3368da..0e56068 100644 --- a/src/components/thread/index.tsx +++ b/src/components/thread/index.tsx @@ -74,8 +74,9 @@ export function Thread() { messages[messages.length - 1].type === "ai" ) { setFirstTokenReceived(true); - prevMessageLength.current = messages.length; } + + prevMessageLength.current = messages.length; }, [messages]); const handleSubmit = (e: FormEvent) => { From d3f8624a7c86a43c9aa21c91c2875640b2820707 Mon Sep 17 00:00:00 2001 From: Tat Dat Duong <david@duong.cz> Date: Tue, 4 Mar 2025 15:50:56 +0100 Subject: [PATCH 11/14] Add handler when clicking on submit --- src/components/thread/index.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/components/thread/index.tsx b/src/components/thread/index.tsx index 0e56068..e6d84a3 100644 --- a/src/components/thread/index.tsx +++ b/src/components/thread/index.tsx @@ -58,6 +58,8 @@ function StickyToBottomContent(props: { } export function Thread() { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const [_, setThreadId] = useQueryParam("threadId", StringParam); const [input, setInput] = useState(""); const [firstTokenReceived, setFirstTokenReceived] = useState(false); const stream = useStreamContext(); @@ -136,10 +138,13 @@ export function Thread() { > {chatStarted && ( <div className="flex items-center justify-between gap-3 p-2 pl-4 z-10 relative"> - <div className="flex gap-2 items-center"> + <button + className="flex gap-2 items-center cursor-pointer" + onClick={() => setThreadId(null)} + > <LangGraphLogoSVG width={32} height={32} /> - <h1 className="text-xl font-medium">LangGraph Chat</h1> - </div> + <span className="text-xl font-medium">LangGraph Chat</span> + </button> <NewThread /> From df2e6a4ccb2463ac6bf2ed922b8ee93e29237fe9 Mon Sep 17 00:00:00 2001 From: Tat Dat Duong <david@duong.cz> Date: Tue, 4 Mar 2025 15:54:43 +0100 Subject: [PATCH 12/14] Cleanup --- src/components/thread/index.tsx | 93 +++++++++++++-------------------- 1 file changed, 36 insertions(+), 57 deletions(-) diff --git a/src/components/thread/index.tsx b/src/components/thread/index.tsx index e6d84a3..b246fc1 100644 --- a/src/components/thread/index.tsx +++ b/src/components/thread/index.tsx @@ -18,23 +18,6 @@ import { SquarePen } from "lucide-react"; import { StringParam, useQueryParam } from "use-query-params"; import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom"; -function NewThread() { - const [threadId, setThreadId] = useQueryParam("threadId", StringParam); - if (!threadId) return null; - - return ( - <TooltipIconButton - size="lg" - className="p-4" - tooltip="New thread" - variant="ghost" - onClick={() => setThreadId(null)} - > - <SquarePen className="size-5" /> - </TooltipIconButton> - ); -} - function StickyToBottomContent(props: { content: ReactNode; footer?: ReactNode; @@ -58,17 +41,16 @@ function StickyToBottomContent(props: { } export function Thread() { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const [_, setThreadId] = useQueryParam("threadId", StringParam); + const [threadId, setThreadId] = useQueryParam("threadId", StringParam); const [input, setInput] = useState(""); const [firstTokenReceived, setFirstTokenReceived] = useState(false); + const stream = useStreamContext(); const messages = stream.messages; const isLoading = stream.isLoading; - const prevMessageLength = useRef(0); - // TODO: this should be part of the useStream hook + const prevMessageLength = useRef(0); useEffect(() => { if ( messages.length !== prevMessageLength.current && @@ -123,20 +105,15 @@ export function Thread() { }); }; - const chatStarted = isLoading || messages.length > 0; - const renderMessages = messages.filter( - (m) => !m.id?.startsWith(DO_NOT_RENDER_ID_PREFIX), - ); - return ( <div className="flex flex-col w-full h-screen overflow-hidden"> <div className={cn( "grow grid grid-rows-[auto_1fr]", - !chatStarted && "grid-rows-[1fr]", + !threadId && "grid-rows-[1fr]", )} > - {chatStarted && ( + {threadId && ( <div className="flex items-center justify-between gap-3 p-2 pl-4 z-10 relative"> <button className="flex gap-2 items-center cursor-pointer" @@ -146,7 +123,15 @@ export function Thread() { <span className="text-xl font-medium">LangGraph Chat</span> </button> - <NewThread /> + <TooltipIconButton + size="lg" + className="p-4" + tooltip="New thread" + variant="ghost" + onClick={() => setThreadId(null)} + > + <SquarePen className="size-5" /> + </TooltipIconButton> <div className="absolute inset-x-0 top-full h-5 bg-gradient-to-b from-background to-background/0" /> </div> @@ -156,36 +141,30 @@ export function Thread() { <StickyToBottomContent className={cn( "absolute inset-0", - !chatStarted && "flex flex-col items-stretch mt-[25vh]", - chatStarted && "grid grid-rows-[1fr_auto]", + !threadId && "flex flex-col items-stretch mt-[25vh]", + threadId && "grid grid-rows-[1fr_auto]", )} - contentClassName="pt-8 pb-16 px-4 max-w-4xl mx-auto flex flex-col gap-4 w-full empty:hidden" + contentClassName="pt-8 pb-16 px-4 max-w-4xl mx-auto flex flex-col gap-4 w-full" content={ <> - {renderMessages.map((message, index) => - message.type === "human" ? ( - <HumanMessage - key={ - "id" in message - ? message.id - : `${message.type}-${index}` - } - message={message} - isLoading={isLoading} - /> - ) : ( - <AssistantMessage - key={ - "id" in message - ? message.id - : `${message.type}-${index}` - } - message={message} - isLoading={isLoading} - handleRegenerate={handleRegenerate} - /> - ), - )} + {messages + .filter((m) => !m.id?.startsWith(DO_NOT_RENDER_ID_PREFIX)) + .map((message, index) => + message.type === "human" ? ( + <HumanMessage + key={message.id || `${message.type}-${index}`} + message={message} + isLoading={isLoading} + /> + ) : ( + <AssistantMessage + key={message.id || `${message.type}-${index}`} + message={message} + isLoading={isLoading} + handleRegenerate={handleRegenerate} + /> + ), + )} {isLoading && !firstTokenReceived && ( <AssistantMessageLoading /> )} @@ -193,7 +172,7 @@ export function Thread() { } footer={ <div className="sticky flex flex-col items-center gap-8 bottom-8 px-4"> - {!chatStarted && ( + {!threadId && ( <div className="flex gap-3 items-center"> <LangGraphLogoSVG className="flex-shrink-0 h-8" /> <h1 className="text-2xl font-medium">LangGraph Chat</h1> From b3099e141993068453005ed7cfd36b3278c2c7c5 Mon Sep 17 00:00:00 2001 From: Tat Dat Duong <david@duong.cz> Date: Tue, 4 Mar 2025 15:56:36 +0100 Subject: [PATCH 13/14] Add scroll to bottom --- src/components/thread/index.tsx | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/src/components/thread/index.tsx b/src/components/thread/index.tsx index b246fc1..49d96f4 100644 --- a/src/components/thread/index.tsx +++ b/src/components/thread/index.tsx @@ -14,7 +14,7 @@ import { } from "@/lib/ensure-tool-responses"; import { LangGraphLogoSVG } from "../icons/langgraph"; import { TooltipIconButton } from "./tooltip-icon-button"; -import { SquarePen } from "lucide-react"; +import { ArrowDown, SquarePen } from "lucide-react"; import { StringParam, useQueryParam } from "use-query-params"; import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom"; @@ -40,6 +40,22 @@ function StickyToBottomContent(props: { ); } +function ScrollToBottom(props: { className?: string }) { + const { isAtBottom, scrollToBottom } = useStickToBottomContext(); + + if (isAtBottom) return null; + return ( + <Button + variant="outline" + className={props.className} + onClick={() => scrollToBottom()} + > + <ArrowDown className="w-4 h-4" /> + <span>Scroll to bottom</span> + </Button> + ); +} + export function Thread() { const [threadId, setThreadId] = useQueryParam("threadId", StringParam); const [input, setInput] = useState(""); @@ -178,6 +194,9 @@ export function Thread() { <h1 className="text-2xl font-medium">LangGraph Chat</h1> </div> )} + + <ScrollToBottom className="absolute bottom-full left-1/2 -translate-x-1/2 mb-4 animate-in fade-in-0 zoom-in-95" /> + <div className="bg-background rounded-2xl border shadow-md mx-auto w-full max-w-4xl"> <form onSubmit={handleSubmit} From 3e9931099506c46a7fca60bd16f95cf405ba0797 Mon Sep 17 00:00:00 2001 From: Tat Dat Duong <david@duong.cz> Date: Tue, 4 Mar 2025 16:11:44 +0100 Subject: [PATCH 14/14] feat: add cancellation button --- src/components/thread/index.tsx | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/src/components/thread/index.tsx b/src/components/thread/index.tsx index 49d96f4..3c4cafd 100644 --- a/src/components/thread/index.tsx +++ b/src/components/thread/index.tsx @@ -14,7 +14,7 @@ import { } from "@/lib/ensure-tool-responses"; import { LangGraphLogoSVG } from "../icons/langgraph"; import { TooltipIconButton } from "./tooltip-icon-button"; -import { ArrowDown, SquarePen } from "lucide-react"; +import { ArrowDown, LoaderCircle, SquarePen } from "lucide-react"; import { StringParam, useQueryParam } from "use-query-params"; import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom"; @@ -211,12 +211,19 @@ export function Thread() { /> <div className="flex items-center justify-end p-2 pt-0"> - <Button - type="submit" - disabled={isLoading || !input.trim()} - > - Send - </Button> + {stream.isLoading ? ( + <Button key="stop" onClick={() => stream.stop()}> + <LoaderCircle className="w-4 h-4 animate-spin" /> + Cancel + </Button> + ) : ( + <Button + type="submit" + disabled={isLoading || !input.trim()} + > + Send + </Button> + )} </div> </form> </div>