This commit is contained in:
Tat Dat Duong
2025-04-28 19:07:04 +02:00
parent 72c13c7ee3
commit e556b13a06
4 changed files with 165 additions and 173 deletions

View File

@@ -1,165 +0,0 @@
import {
HTMLAttributes,
ReactNode,
createContext,
useCallback,
useContext,
useEffect,
useId,
useLayoutEffect,
useRef,
useState,
} from "react";
import { createPortal } from "react-dom";
type Setter<T> = (value: T | ((value: T) => T)) => void;
const createArtifactSlot = () => {
const SlotContext = createContext<{
open: [string | null, Setter<string | null>];
mounted: [string | null, Setter<string | null>];
title: [HTMLElement | null, Setter<HTMLElement | null>];
content: [HTMLElement | null, Setter<HTMLElement | null>];
}>(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<HTMLDivElement>) => {
const context = useContext(SlotContext);
const [mounted] = context.mounted;
const ref = useRef<HTMLDivElement>(null);
const [, setStateRef] = context.content;
useLayoutEffect(
() => setStateRef?.(mounted ? ref.current : null),
[setStateRef, mounted],
);
if (!mounted) return null;
return (
<div
{...props}
ref={ref}
/>
);
};
const Title = (props: HTMLAttributes<HTMLDivElement>) => {
const context = useContext(SlotContext);
const ref = useRef<HTMLDivElement>(null);
const [, setStateRef] = context.title;
useLayoutEffect(() => setStateRef?.(ref.current), [setStateRef]);
return (
<div
{...props}
ref={ref}
/>
);
};
function Context(props: { children?: ReactNode }) {
const content = useState<HTMLElement | null>(null);
const title = useState<HTMLElement | null>(null);
const open = useState<string | null>(null);
const mounted = useState<string | null>(null);
return (
<SlotContext.Provider value={{ open, mounted, title, content }}>
{props.children}
</SlotContext.Provider>
);
}
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 (
<Fill
id={id}
title={props.title}
>
{props.children}
</Fill>
);
},
[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();

View File

@@ -0,0 +1,152 @@
import {
HTMLAttributes,
ReactNode,
createContext,
useCallback,
useContext,
useEffect,
useId,
useLayoutEffect,
useRef,
useState,
} from "react";
import { createPortal } from "react-dom";
type Setter<T> = (value: T | ((value: T) => T)) => void;
const ArtifactSlotContext = createContext<{
open: [string | null, Setter<string | null>];
mounted: [string | null, Setter<string | null>];
title: [HTMLElement | null, Setter<HTMLElement | null>];
content: [HTMLElement | null, Setter<HTMLElement | null>];
}>(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<HTMLDivElement>) {
const context = useContext(ArtifactSlotContext);
const [mounted] = context.mounted;
const ref = useRef<HTMLDivElement>(null);
const [, setStateRef] = context.content;
useLayoutEffect(
() => setStateRef?.(mounted ? ref.current : null),
[setStateRef, mounted],
);
if (!mounted) return null;
return (
<div
{...props}
ref={ref}
/>
);
}
export function ArtifactTitle(props: HTMLAttributes<HTMLDivElement>) {
const context = useContext(ArtifactSlotContext);
const ref = useRef<HTMLDivElement>(null);
const [, setStateRef] = context.title;
useLayoutEffect(() => setStateRef?.(ref.current), [setStateRef]);
return (
<div
{...props}
ref={ref}
/>
);
}
export function ArtifactContext(props: { children?: ReactNode }) {
const content = useState<HTMLElement | null>(null);
const title = useState<HTMLElement | null>(null);
const open = useState<string | null>(null);
const mounted = useState<string | null>(null);
return (
<ArtifactSlotContext.Provider value={{ open, mounted, title, content }}>
{props.children}
</ArtifactSlotContext.Provider>
);
}
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 (
<ArtifactFill
id={id}
title={props.title}
>
{props.children}
</ArtifactFill>
);
},
[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;
}

View File

@@ -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 (
<div
@@ -118,7 +123,7 @@ function ArtifactLayout(props: { children: ReactNode }) {
<div className="relative flex flex-col border-l">
<div className="absolute inset-0 flex min-w-[30vw] flex-col">
<div className="grid grid-cols-[1fr_auto] border-b p-4">
<Artifact.Title className="truncate overflow-hidden" />
<ArtifactTitle className="truncate overflow-hidden" />
<button
onClick={onClose}
className="cursor-pointer"
@@ -126,7 +131,7 @@ function ArtifactLayout(props: { children: ReactNode }) {
<XIcon className="size-5" />
</button>
</div>
<Artifact.Content className="relative flex-grow" />
<ArtifactContent className="relative flex-grow" />
</div>
</div>
</div>
@@ -269,7 +274,7 @@ export function Thread() {
</motion.div>
</div>
<Artifact.Context>
<ArtifactContext>
<ArtifactLayout>
<motion.div
className={cn(
@@ -495,7 +500,7 @@ export function Thread() {
</StickToBottom>
</motion.div>
</ArtifactLayout>
</Artifact.Context>
</ArtifactContext>
</div>
);
}

View File

@@ -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<typeof useStreamContext>;
}) {
const artifact = Artifact.useArtifact();
const artifact = useArtifact();
const { values } = useStreamContext();
const customComponents = values.ui?.filter(
(ui) => ui.metadata?.message_id === message.id,