diff --git a/apps/web/src/features/chat/components/artifact/artifact.tsx b/apps/web/src/features/chat/components/artifact/artifact.tsx new file mode 100644 index 00000000..0497b2a5 --- /dev/null +++ b/apps/web/src/features/chat/components/artifact/artifact.tsx @@ -0,0 +1,189 @@ +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]; + + context: [Record, Setter>]; +}>(null!); + +/** + * Headless component that will obtain the title and content of the artifact + * and render them in place of the `ArtifactContent` and `ArtifactTitle` components via + * React Portals. + */ +const ArtifactSlot = (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 ArtifactProvider(props: { children?: ReactNode }) { + const content = useState(null); + const title = useState(null); + + const open = useState(null); + const mounted = useState(null); + const context = useState>({}); + + return ( + + {props.children} + + ); +} + +/** + * Provides a value to be passed into `meta.artifact` field + * of the `LoadExternalComponent` component, to be consumed by the `useArtifact` hook + * on the generative UI side. + */ +export function useArtifact() { + const id = useId(); + const context = useContext(ArtifactSlotContext); + const [ctxOpen, ctxSetOpen] = context.open; + const [ctxContext, ctxSetContext] = context.context; + 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 [ + ArtifactContent, + { open, setOpen, context: ctxContext, setContext: ctxSetContext }, + ] as [ + typeof ArtifactContent, + { + open: typeof open; + setOpen: typeof setOpen; + context: typeof ctxContext; + setContext: typeof ctxSetContext; + }, + ]; +} + +/** + * General hook for detecting if any artifact is open. + */ +export function useArtifactOpen() { + const context = useContext(ArtifactSlotContext); + const [ctxOpen, setCtxOpen] = context.open; + + const open = ctxOpen !== null; + const onClose = useCallback(() => setCtxOpen(null), [setCtxOpen]); + + return [open, onClose] as const; +} + +/** + * Artifacts may at their discretion provide additional context + * that will be used when creating a new run. + */ +export function useArtifactContext() { + const context = useContext(ArtifactSlotContext); + return context.context; +} diff --git a/apps/web/src/features/chat/components/thread/index.tsx b/apps/web/src/features/chat/components/thread/index.tsx index cc991d93..124fbc6e 100644 --- a/apps/web/src/features/chat/components/thread/index.tsx +++ b/apps/web/src/features/chat/components/thread/index.tsx @@ -12,7 +12,7 @@ import { import { HumanMessage } from "@/features/chat/components/thread/messages/human"; import { LangGraphLogoSVG } from "@/components/icons/langgraph"; import { TooltipIconButton } from "@/components/ui/tooltip-icon-button"; -import { ArrowDown, LoaderCircle, SquarePen } from "lucide-react"; +import { ArrowDown, LoaderCircle, SquarePen, XIcon } from "lucide-react"; import { useQueryState, parseAsBoolean } from "nuqs"; import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom"; import { toast } from "sonner"; @@ -21,6 +21,12 @@ import { Switch } from "@/components/ui/switch"; import { ensureToolCallsHaveResponses } from "@/features/chat/utils/tool-responses"; import { DO_NOT_RENDER_ID_PREFIX } from "@/constants"; import { useConfigStore } from "../../hooks/use-config-store"; +import { + ArtifactContent, + ArtifactTitle, + useArtifactOpen, +} from "../artifact/artifact"; +import { useArtifactContext } from "../artifact/artifact"; function StickyToBottomContent(props: { content: ReactNode; @@ -64,7 +70,10 @@ function ScrollToBottom(props: { className?: string }) { } export function Thread() { - const [threadId, setThreadId] = useQueryState("threadId"); + const [artifactContext, setArtifactContext] = useArtifactContext(); + const [artifactOpen, closeArtifact] = useArtifactOpen(); + + const [threadId, _setThreadId] = useQueryState("threadId"); const [agentId] = useQueryState("agentId"); const { getAgentConfig } = useConfigStore(); const [hideToolCalls, setHideToolCalls] = useQueryState( @@ -80,6 +89,14 @@ export function Thread() { const lastError = useRef(undefined); + const setThreadId = (id: string | null) => { + _setThreadId(id); + + // close artifact and reset artifact context + closeArtifact(); + setArtifactContext({}); + }; + useEffect(() => { if (!stream.error) { lastError.current = undefined; @@ -135,8 +152,12 @@ export function Thread() { }; const toolMessages = ensureToolCallsHaveResponses(stream.messages); + + const context = + Object.keys(artifactContext).length > 0 ? artifactContext : undefined; + stream.submit( - { messages: [...toolMessages, newHumanMessage] }, + { messages: [...toolMessages, newHumanMessage], context }, { streamMode: ["values"], optimisticValues: (prev) => ({ @@ -179,138 +200,162 @@ export function Thread() { ); return ( -
- - - {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. +
+
+ + + {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 && ( - - )} - {isLoading && !firstTokenReceived && } - - } - footer={ -
- {!chatStarted && ( -
- -

- Open Agent Platform -

-
- )} + {hasNoAIOrToolMessages && !!stream.interrupt && ( + + )} + {isLoading && !firstTokenReceived && ( + + )} + + } + footer={ +
+ {!chatStarted && ( +
+ +

+ Open Agent Platform +

+
+ )} - + -
-
-