diff --git a/src/components/thread/artifact-slot.tsx b/src/components/thread/artifact-slot.tsx deleted file mode 100644 index 2f7a356..0000000 --- a/src/components/thread/artifact-slot.tsx +++ /dev/null @@ -1,165 +0,0 @@ -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/artifact.tsx b/src/components/thread/artifact.tsx new file mode 100644 index 0000000..3a4426d --- /dev/null +++ b/src/components/thread/artifact.tsx @@ -0,0 +1,152 @@ +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 ArtifactSlotContext = createContext<{ + open: [string | null, Setter]; + mounted: [string | null, Setter]; + + title: [HTMLElement | null, Setter]; + content: [HTMLElement | null, Setter]; +}>(null!); + +const ArtifactFill = (props: { + id: string; + children?: ReactNode; + title?: ReactNode; +}) => { + const context = useContext(ArtifactSlotContext); + + 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} + + ); +}; + +export function ArtifactContent(props: HTMLAttributes) { + const context = useContext(ArtifactSlotContext); + + 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 ( +
+ ); +} + +export function ArtifactTitle(props: HTMLAttributes) { + const context = useContext(ArtifactSlotContext); + + const ref = useRef(null); + const [, setStateRef] = context.title; + + useLayoutEffect(() => setStateRef?.(ref.current), [setStateRef]); + + return ( +
+ ); +} + +export function ArtifactContext(props: { children?: ReactNode }) { + const content = useState(null); + const title = useState(null); + + const open = useState(null); + const mounted = useState(null); + + return ( + + {props.children} + + ); +} + +export function useArtifact() { + const id = useId(); + const context = useContext(ArtifactSlotContext); + 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 }; +} + +export function useAnyArtifactOpen() { + const context = useContext(ArtifactSlotContext); + const [ctxOpen, setCtxOpen] = context.open; + + const open = ctxOpen !== null; + const onClose = useCallback(() => { + setCtxOpen(null); + }, [setCtxOpen]); + + return [open, onClose] as const; +} diff --git a/src/components/thread/index.tsx b/src/components/thread/index.tsx index bf9fa9d..34d70d1 100644 --- a/src/components/thread/index.tsx +++ b/src/components/thread/index.tsx @@ -36,7 +36,12 @@ import { TooltipProvider, TooltipTrigger, } from "../ui/tooltip"; -import { Artifact } from "./artifact-slot"; +import { + useAnyArtifactOpen, + ArtifactContent, + ArtifactContext, + ArtifactTitle, +} from "./artifact"; function StickyToBottomContent(props: { content: ReactNode; @@ -104,7 +109,7 @@ function OpenGitHubRepo() { } function ArtifactLayout(props: { children: ReactNode }) { - const [open, onClose] = Artifact.useAnyArtifactOpen(); + const [open, onClose] = useAnyArtifactOpen(); return (
- +
- +
@@ -269,7 +274,7 @@ export function Thread() {
- + - +
); } diff --git a/src/components/thread/messages/ai.tsx b/src/components/thread/messages/ai.tsx index 402bd67..828ea29 100644 --- a/src/components/thread/messages/ai.tsx +++ b/src/components/thread/messages/ai.tsx @@ -13,7 +13,7 @@ import { isAgentInboxInterruptSchema } from "@/lib/agent-inbox-interrupt"; import { ThreadView } from "../agent-inbox"; import { useQueryState, parseAsBoolean } from "nuqs"; import { GenericInterruptView } from "./generic-interrupt"; -import { Artifact } from "../artifact-slot"; +import { useArtifact } from "../artifact"; function CustomComponent({ message, @@ -22,7 +22,7 @@ function CustomComponent({ message: Message; thread: ReturnType; }) { - const artifact = Artifact.useArtifact(); + const artifact = useArtifact(); const { values } = useStreamContext(); const customComponents = values.ui?.filter( (ui) => ui.metadata?.message_id === message.id,