From 72c13c7ee3c6d38bb676b0444887b1d58d32d393 Mon Sep 17 00:00:00 2001 From: Tat Dat Duong Date: Mon, 28 Apr 2025 18:59:26 +0200 Subject: [PATCH 1/8] Initial pass on artifacts --- src/components/thread/artifact-slot.tsx | 165 +++++++++ src/components/thread/index.tsx | 465 +++++++++++++----------- src/components/thread/messages/ai.tsx | 15 +- 3 files changed, 425 insertions(+), 220 deletions(-) create mode 100644 src/components/thread/artifact-slot.tsx diff --git a/src/components/thread/artifact-slot.tsx b/src/components/thread/artifact-slot.tsx new file mode 100644 index 0000000..2f7a356 --- /dev/null +++ b/src/components/thread/artifact-slot.tsx @@ -0,0 +1,165 @@ +import { + HTMLAttributes, + ReactNode, + createContext, + useCallback, + useContext, + useEffect, + useId, + useLayoutEffect, + useRef, + useState, +} from "react"; +import { createPortal } from "react-dom"; + +type Setter = (value: T | ((value: T) => T)) => void; + +const createArtifactSlot = () => { + const SlotContext = createContext<{ + open: [string | null, Setter]; + mounted: [string | null, Setter]; + + title: [HTMLElement | null, Setter]; + content: [HTMLElement | null, Setter]; + }>(null!); + + const Fill = (props: { + id: string; + children?: ReactNode; + title?: ReactNode; + }) => { + const context = useContext(SlotContext); + + const [ctxMounted, ctxSetMounted] = context.mounted; + const [content] = context.content; + const [title] = context.title; + + const isMounted = ctxMounted === props.id; + const isEmpty = props.children == null && props.title == null; + + useEffect(() => { + if (isEmpty) { + ctxSetMounted((open) => (open === props.id ? null : open)); + } + }, [isEmpty, ctxSetMounted, props.id]); + + if (!isMounted) return null; + return ( + <> + {title != null ? createPortal(<>{props.title}, title) : null} + {content != null ? createPortal(<>{props.children}, content) : null} + + ); + }; + + const Content = (props: HTMLAttributes) => { + const context = useContext(SlotContext); + + const [mounted] = context.mounted; + const ref = useRef(null); + const [, setStateRef] = context.content; + + useLayoutEffect( + () => setStateRef?.(mounted ? ref.current : null), + [setStateRef, mounted], + ); + + if (!mounted) return null; + return ( +
+ ); + }; + + const Title = (props: HTMLAttributes) => { + const context = useContext(SlotContext); + + const ref = useRef(null); + const [, setStateRef] = context.title; + + useLayoutEffect(() => setStateRef?.(ref.current), [setStateRef]); + + return ( +
+ ); + }; + + function Context(props: { children?: ReactNode }) { + const content = useState(null); + const title = useState(null); + + const open = useState(null); + const mounted = useState(null); + + return ( + + {props.children} + + ); + } + + function useArtifact() { + const id = useId(); + const context = useContext(SlotContext); + const [ctxOpen, ctxSetOpen] = context.open; + const [, ctxSetMounted] = context.mounted; + + const open = ctxOpen === id; + const setOpen = useCallback( + (value: boolean | ((value: boolean) => boolean)) => { + if (typeof value === "boolean") { + ctxSetOpen(value ? id : null); + } else { + ctxSetOpen((open) => (open === id ? null : id)); + } + + ctxSetMounted(id); + }, + [ctxSetOpen, ctxSetMounted, id], + ); + + const ArtifactContent = useCallback( + (props: { title?: React.ReactNode; children: React.ReactNode }) => { + return ( + + {props.children} + + ); + }, + [id], + ); + + return { open, setOpen, content: ArtifactContent }; + } + + function useAnyArtifactOpen() { + const context = useContext(SlotContext); + const [ctxOpen, setCtxOpen] = context.open; + + const open = ctxOpen !== null; + const onClose = useCallback(() => { + setCtxOpen(null); + }, [setCtxOpen]); + + return [open, onClose] as const; + } + + return { + Context, + Content, + Title, + + useArtifact, + useAnyArtifactOpen, + }; +}; + +export const Artifact = createArtifactSlot(); diff --git a/src/components/thread/index.tsx b/src/components/thread/index.tsx index 1cec4a2..bf9fa9d 100644 --- a/src/components/thread/index.tsx +++ b/src/components/thread/index.tsx @@ -20,6 +20,7 @@ import { PanelRightOpen, PanelRightClose, SquarePen, + XIcon, } from "lucide-react"; import { useQueryState, parseAsBoolean } from "nuqs"; import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom"; @@ -35,6 +36,7 @@ import { TooltipProvider, TooltipTrigger, } from "../ui/tooltip"; +import { Artifact } from "./artifact-slot"; function StickyToBottomContent(props: { content: ReactNode; @@ -101,6 +103,36 @@ function OpenGitHubRepo() { ); } +function ArtifactLayout(props: { children: ReactNode }) { + const [open, onClose] = Artifact.useAnyArtifactOpen(); + + return ( +
+ {props.children} + +
+
+
+ + +
+ +
+
+
+ ); +} + export function Thread() { const [threadId, setThreadId] = useQueryState("threadId"); const [chatHistoryOpen, setChatHistoryOpen] = useQueryState( @@ -236,229 +268,234 @@ export function Thread() {
- - {!chatStarted && ( -
-
- {(!chatHistoryOpen || !isLargeScreen) && ( - - )} -
-
- -
-
- )} - {chatStarted && ( -
-
-
- {(!chatHistoryOpen || !isLargeScreen) && ( - - )} -
- setThreadId(null)} - animate={{ - marginLeft: !chatHistoryOpen ? 48 : 0, - }} - transition={{ - type: "spring", - stiffness: 300, - damping: 30, - }} - > - - - Agent Chat - - -
-
-
- -
- setThreadId(null)} - > - - -
- -
-
- )} - - - + + - {messages - .filter((m) => !m.id?.startsWith(DO_NOT_RENDER_ID_PREFIX)) - .map((message, index) => - message.type === "human" ? ( - - ) : ( + layout={isLargeScreen} + animate={{ + marginLeft: chatHistoryOpen ? (isLargeScreen ? 300 : 0) : 0, + width: chatHistoryOpen + ? isLargeScreen + ? "calc(100% - 300px)" + : "100%" + : "100%", + }} + transition={ + isLargeScreen + ? { type: "spring", stiffness: 300, damping: 30 } + : { duration: 0 } + } + > + {!chatStarted && ( +
+
+ {(!chatHistoryOpen || !isLargeScreen) && ( + + )} +
+
+ +
+
+ )} + {chatStarted && ( +
+
+
+ {(!chatHistoryOpen || !isLargeScreen) && ( + + )} +
+ setThreadId(null)} + animate={{ + marginLeft: !chatHistoryOpen ? 48 : 0, + }} + transition={{ + type: "spring", + stiffness: 300, + damping: 30, + }} + > + + + Agent Chat + + +
+ +
+
+ +
+ setThreadId(null)} + > + + +
+ +
+
+ )} + + + + {messages + .filter((m) => !m.id?.startsWith(DO_NOT_RENDER_ID_PREFIX)) + .map((message, index) => + message.type === "human" ? ( + + ) : ( + + ), + )} + {/* Special rendering case where there are no AI/tool messages, but there is an interrupt. + We need to render it outside of the messages list, since there are no messages to render */} + {hasNoAIOrToolMessages && !!stream.interrupt && ( - ), - )} - {/* Special rendering case where there are no AI/tool messages, but there is an interrupt. - We need to render it outside of the messages list, since there are no messages to render */} - {hasNoAIOrToolMessages && !!stream.interrupt && ( - - )} - {isLoading && !firstTokenReceived && ( - - )} - - } - footer={ -
- {!chatStarted && ( -
- -

- Agent Chat -

-
- )} - - - -
-
-