Refactor
This commit is contained in:
@@ -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();
|
|
||||||
152
src/components/thread/artifact.tsx
Normal file
152
src/components/thread/artifact.tsx
Normal 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;
|
||||||
|
}
|
||||||
@@ -36,7 +36,12 @@ import {
|
|||||||
TooltipProvider,
|
TooltipProvider,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "../ui/tooltip";
|
} from "../ui/tooltip";
|
||||||
import { Artifact } from "./artifact-slot";
|
import {
|
||||||
|
useAnyArtifactOpen,
|
||||||
|
ArtifactContent,
|
||||||
|
ArtifactContext,
|
||||||
|
ArtifactTitle,
|
||||||
|
} from "./artifact";
|
||||||
|
|
||||||
function StickyToBottomContent(props: {
|
function StickyToBottomContent(props: {
|
||||||
content: ReactNode;
|
content: ReactNode;
|
||||||
@@ -104,7 +109,7 @@ function OpenGitHubRepo() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ArtifactLayout(props: { children: ReactNode }) {
|
function ArtifactLayout(props: { children: ReactNode }) {
|
||||||
const [open, onClose] = Artifact.useAnyArtifactOpen();
|
const [open, onClose] = useAnyArtifactOpen();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -118,7 +123,7 @@ function ArtifactLayout(props: { children: ReactNode }) {
|
|||||||
<div className="relative flex flex-col border-l">
|
<div className="relative flex flex-col border-l">
|
||||||
<div className="absolute inset-0 flex min-w-[30vw] flex-col">
|
<div className="absolute inset-0 flex min-w-[30vw] flex-col">
|
||||||
<div className="grid grid-cols-[1fr_auto] border-b p-4">
|
<div className="grid grid-cols-[1fr_auto] border-b p-4">
|
||||||
<Artifact.Title className="truncate overflow-hidden" />
|
<ArtifactTitle className="truncate overflow-hidden" />
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="cursor-pointer"
|
className="cursor-pointer"
|
||||||
@@ -126,7 +131,7 @@ function ArtifactLayout(props: { children: ReactNode }) {
|
|||||||
<XIcon className="size-5" />
|
<XIcon className="size-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<Artifact.Content className="relative flex-grow" />
|
<ArtifactContent className="relative flex-grow" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -269,7 +274,7 @@ export function Thread() {
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Artifact.Context>
|
<ArtifactContext>
|
||||||
<ArtifactLayout>
|
<ArtifactLayout>
|
||||||
<motion.div
|
<motion.div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -495,7 +500,7 @@ export function Thread() {
|
|||||||
</StickToBottom>
|
</StickToBottom>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</ArtifactLayout>
|
</ArtifactLayout>
|
||||||
</Artifact.Context>
|
</ArtifactContext>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { isAgentInboxInterruptSchema } from "@/lib/agent-inbox-interrupt";
|
|||||||
import { ThreadView } from "../agent-inbox";
|
import { ThreadView } from "../agent-inbox";
|
||||||
import { useQueryState, parseAsBoolean } from "nuqs";
|
import { useQueryState, parseAsBoolean } from "nuqs";
|
||||||
import { GenericInterruptView } from "./generic-interrupt";
|
import { GenericInterruptView } from "./generic-interrupt";
|
||||||
import { Artifact } from "../artifact-slot";
|
import { useArtifact } from "../artifact";
|
||||||
|
|
||||||
function CustomComponent({
|
function CustomComponent({
|
||||||
message,
|
message,
|
||||||
@@ -22,7 +22,7 @@ function CustomComponent({
|
|||||||
message: Message;
|
message: Message;
|
||||||
thread: ReturnType<typeof useStreamContext>;
|
thread: ReturnType<typeof useStreamContext>;
|
||||||
}) {
|
}) {
|
||||||
const artifact = Artifact.useArtifact();
|
const artifact = useArtifact();
|
||||||
const { values } = useStreamContext();
|
const { values } = useStreamContext();
|
||||||
const customComponents = values.ui?.filter(
|
const customComponents = values.ui?.filter(
|
||||||
(ui) => ui.metadata?.message_id === message.id,
|
(ui) => ui.metadata?.message_id === message.id,
|
||||||
|
|||||||
Reference in New Issue
Block a user