Initial pass on artifacts
This commit is contained in:
165
src/components/thread/artifact-slot.tsx
Normal file
165
src/components/thread/artifact-slot.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
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();
|
||||
@@ -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 (
|
||||
<div
|
||||
className={cn(
|
||||
"grid w-full grid-cols-[1fr_0fr] transition-all duration-500",
|
||||
open && "grid-cols-[3fr_2fr]",
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
|
||||
<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" />
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<XIcon className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
<Artifact.Content className="relative flex-grow" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Thread() {
|
||||
const [threadId, setThreadId] = useQueryState("threadId");
|
||||
const [chatHistoryOpen, setChatHistoryOpen] = useQueryState(
|
||||
@@ -236,229 +268,234 @@ export function Thread() {
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
<motion.div
|
||||
className={cn(
|
||||
"relative flex min-w-0 flex-1 flex-col overflow-hidden",
|
||||
!chatStarted && "grid-rows-[1fr]",
|
||||
)}
|
||||
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 && (
|
||||
<div className="absolute top-0 left-0 z-10 flex w-full items-center justify-between gap-3 p-2 pl-4">
|
||||
<div>
|
||||
{(!chatHistoryOpen || !isLargeScreen) && (
|
||||
<Button
|
||||
className="hover:bg-gray-100"
|
||||
variant="ghost"
|
||||
onClick={() => setChatHistoryOpen((p) => !p)}
|
||||
>
|
||||
{chatHistoryOpen ? (
|
||||
<PanelRightOpen className="size-5" />
|
||||
) : (
|
||||
<PanelRightClose className="size-5" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="absolute top-2 right-4 flex items-center">
|
||||
<OpenGitHubRepo />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{chatStarted && (
|
||||
<div className="relative z-10 flex items-center justify-between gap-3 p-2">
|
||||
<div className="relative flex items-center justify-start gap-2">
|
||||
<div className="absolute left-0 z-10">
|
||||
{(!chatHistoryOpen || !isLargeScreen) && (
|
||||
<Button
|
||||
className="hover:bg-gray-100"
|
||||
variant="ghost"
|
||||
onClick={() => setChatHistoryOpen((p) => !p)}
|
||||
>
|
||||
{chatHistoryOpen ? (
|
||||
<PanelRightOpen className="size-5" />
|
||||
) : (
|
||||
<PanelRightClose className="size-5" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<motion.button
|
||||
className="flex cursor-pointer items-center gap-2"
|
||||
onClick={() => setThreadId(null)}
|
||||
animate={{
|
||||
marginLeft: !chatHistoryOpen ? 48 : 0,
|
||||
}}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 300,
|
||||
damping: 30,
|
||||
}}
|
||||
>
|
||||
<LangGraphLogoSVG
|
||||
width={32}
|
||||
height={32}
|
||||
/>
|
||||
<span className="text-xl font-semibold tracking-tight">
|
||||
Agent Chat
|
||||
</span>
|
||||
</motion.button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center">
|
||||
<OpenGitHubRepo />
|
||||
</div>
|
||||
<TooltipIconButton
|
||||
size="lg"
|
||||
className="p-4"
|
||||
tooltip="New thread"
|
||||
variant="ghost"
|
||||
onClick={() => setThreadId(null)}
|
||||
>
|
||||
<SquarePen className="size-5" />
|
||||
</TooltipIconButton>
|
||||
</div>
|
||||
|
||||
<div className="from-background to-background/0 absolute inset-x-0 top-full h-5 bg-gradient-to-b" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<StickToBottom className="relative flex-1 overflow-hidden">
|
||||
<StickyToBottomContent
|
||||
<Artifact.Context>
|
||||
<ArtifactLayout>
|
||||
<motion.div
|
||||
className={cn(
|
||||
"absolute inset-0 overflow-y-scroll px-4 [&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-gray-300 [&::-webkit-scrollbar-track]:bg-transparent",
|
||||
!chatStarted && "mt-[25vh] flex flex-col items-stretch",
|
||||
chatStarted && "grid grid-rows-[1fr_auto]",
|
||||
"relative flex min-w-0 flex-1 flex-col overflow-hidden",
|
||||
!chatStarted && "grid-rows-[1fr]",
|
||||
)}
|
||||
contentClassName="pt-8 pb-16 max-w-3xl mx-auto flex flex-col gap-4 w-full"
|
||||
content={
|
||||
<>
|
||||
{messages
|
||||
.filter((m) => !m.id?.startsWith(DO_NOT_RENDER_ID_PREFIX))
|
||||
.map((message, index) =>
|
||||
message.type === "human" ? (
|
||||
<HumanMessage
|
||||
key={message.id || `${message.type}-${index}`}
|
||||
message={message}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
) : (
|
||||
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 && (
|
||||
<div className="absolute top-0 left-0 z-10 flex w-full items-center justify-between gap-3 p-2 pl-4">
|
||||
<div>
|
||||
{(!chatHistoryOpen || !isLargeScreen) && (
|
||||
<Button
|
||||
className="hover:bg-gray-100"
|
||||
variant="ghost"
|
||||
onClick={() => setChatHistoryOpen((p) => !p)}
|
||||
>
|
||||
{chatHistoryOpen ? (
|
||||
<PanelRightOpen className="size-5" />
|
||||
) : (
|
||||
<PanelRightClose className="size-5" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="absolute top-2 right-4 flex items-center">
|
||||
<OpenGitHubRepo />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{chatStarted && (
|
||||
<div className="relative z-10 flex items-center justify-between gap-3 p-2">
|
||||
<div className="relative flex items-center justify-start gap-2">
|
||||
<div className="absolute left-0 z-10">
|
||||
{(!chatHistoryOpen || !isLargeScreen) && (
|
||||
<Button
|
||||
className="hover:bg-gray-100"
|
||||
variant="ghost"
|
||||
onClick={() => setChatHistoryOpen((p) => !p)}
|
||||
>
|
||||
{chatHistoryOpen ? (
|
||||
<PanelRightOpen className="size-5" />
|
||||
) : (
|
||||
<PanelRightClose className="size-5" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<motion.button
|
||||
className="flex cursor-pointer items-center gap-2"
|
||||
onClick={() => setThreadId(null)}
|
||||
animate={{
|
||||
marginLeft: !chatHistoryOpen ? 48 : 0,
|
||||
}}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 300,
|
||||
damping: 30,
|
||||
}}
|
||||
>
|
||||
<LangGraphLogoSVG
|
||||
width={32}
|
||||
height={32}
|
||||
/>
|
||||
<span className="text-xl font-semibold tracking-tight">
|
||||
Agent Chat
|
||||
</span>
|
||||
</motion.button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center">
|
||||
<OpenGitHubRepo />
|
||||
</div>
|
||||
<TooltipIconButton
|
||||
size="lg"
|
||||
className="p-4"
|
||||
tooltip="New thread"
|
||||
variant="ghost"
|
||||
onClick={() => setThreadId(null)}
|
||||
>
|
||||
<SquarePen className="size-5" />
|
||||
</TooltipIconButton>
|
||||
</div>
|
||||
|
||||
<div className="from-background to-background/0 absolute inset-x-0 top-full h-5 bg-gradient-to-b" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<StickToBottom className="relative flex-1 overflow-hidden">
|
||||
<StickyToBottomContent
|
||||
className={cn(
|
||||
"absolute inset-0 overflow-y-scroll px-4 [&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-gray-300 [&::-webkit-scrollbar-track]:bg-transparent",
|
||||
!chatStarted && "mt-[25vh] flex flex-col items-stretch",
|
||||
chatStarted && "grid grid-rows-[1fr_auto]",
|
||||
)}
|
||||
contentClassName="pt-8 pb-16 max-w-3xl mx-auto flex flex-col gap-4 w-full"
|
||||
content={
|
||||
<>
|
||||
{messages
|
||||
.filter((m) => !m.id?.startsWith(DO_NOT_RENDER_ID_PREFIX))
|
||||
.map((message, index) =>
|
||||
message.type === "human" ? (
|
||||
<HumanMessage
|
||||
key={message.id || `${message.type}-${index}`}
|
||||
message={message}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
) : (
|
||||
<AssistantMessage
|
||||
key={message.id || `${message.type}-${index}`}
|
||||
message={message}
|
||||
isLoading={isLoading}
|
||||
handleRegenerate={handleRegenerate}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
{/* 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 && (
|
||||
<AssistantMessage
|
||||
key={message.id || `${message.type}-${index}`}
|
||||
message={message}
|
||||
key="interrupt-msg"
|
||||
message={undefined}
|
||||
isLoading={isLoading}
|
||||
handleRegenerate={handleRegenerate}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
{/* 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 && (
|
||||
<AssistantMessage
|
||||
key="interrupt-msg"
|
||||
message={undefined}
|
||||
isLoading={isLoading}
|
||||
handleRegenerate={handleRegenerate}
|
||||
/>
|
||||
)}
|
||||
{isLoading && !firstTokenReceived && (
|
||||
<AssistantMessageLoading />
|
||||
)}
|
||||
</>
|
||||
}
|
||||
footer={
|
||||
<div className="sticky bottom-0 flex flex-col items-center gap-8 bg-white">
|
||||
{!chatStarted && (
|
||||
<div className="flex items-center gap-3">
|
||||
<LangGraphLogoSVG className="h-8 flex-shrink-0" />
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
Agent Chat
|
||||
</h1>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ScrollToBottom className="animate-in fade-in-0 zoom-in-95 absolute bottom-full left-1/2 mb-4 -translate-x-1/2" />
|
||||
|
||||
<div className="bg-muted relative z-10 mx-auto mb-8 w-full max-w-3xl rounded-2xl border shadow-xs">
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="mx-auto grid max-w-3xl grid-rows-[1fr_auto] gap-2"
|
||||
>
|
||||
<textarea
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (
|
||||
e.key === "Enter" &&
|
||||
!e.shiftKey &&
|
||||
!e.metaKey &&
|
||||
!e.nativeEvent.isComposing
|
||||
) {
|
||||
e.preventDefault();
|
||||
const el = e.target as HTMLElement | undefined;
|
||||
const form = el?.closest("form");
|
||||
form?.requestSubmit();
|
||||
}
|
||||
}}
|
||||
placeholder="Type your message..."
|
||||
className="field-sizing-content resize-none border-none bg-transparent p-3.5 pb-0 shadow-none ring-0 outline-none focus:ring-0 focus:outline-none"
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between p-2 pt-4">
|
||||
<div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id="render-tool-calls"
|
||||
checked={hideToolCalls ?? false}
|
||||
onCheckedChange={setHideToolCalls}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="render-tool-calls"
|
||||
className="text-sm text-gray-600"
|
||||
>
|
||||
Hide Tool Calls
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
{isLoading && !firstTokenReceived && (
|
||||
<AssistantMessageLoading />
|
||||
)}
|
||||
</>
|
||||
}
|
||||
footer={
|
||||
<div className="sticky bottom-0 flex flex-col items-center gap-8 bg-white">
|
||||
{!chatStarted && (
|
||||
<div className="flex items-center gap-3">
|
||||
<LangGraphLogoSVG className="h-8 flex-shrink-0" />
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
Agent Chat
|
||||
</h1>
|
||||
</div>
|
||||
{stream.isLoading ? (
|
||||
<Button
|
||||
key="stop"
|
||||
onClick={() => stream.stop()}
|
||||
>
|
||||
<LoaderCircle className="h-4 w-4 animate-spin" />
|
||||
Cancel
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="submit"
|
||||
className="shadow-md transition-all"
|
||||
disabled={isLoading || !input.trim()}
|
||||
>
|
||||
Send
|
||||
</Button>
|
||||
)}
|
||||
)}
|
||||
|
||||
<ScrollToBottom className="animate-in fade-in-0 zoom-in-95 absolute bottom-full left-1/2 mb-4 -translate-x-1/2" />
|
||||
|
||||
<div className="bg-muted relative z-10 mx-auto mb-8 w-full max-w-3xl rounded-2xl border shadow-xs">
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="mx-auto grid max-w-3xl grid-rows-[1fr_auto] gap-2"
|
||||
>
|
||||
<textarea
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (
|
||||
e.key === "Enter" &&
|
||||
!e.shiftKey &&
|
||||
!e.metaKey &&
|
||||
!e.nativeEvent.isComposing
|
||||
) {
|
||||
e.preventDefault();
|
||||
const el = e.target as HTMLElement | undefined;
|
||||
const form = el?.closest("form");
|
||||
form?.requestSubmit();
|
||||
}
|
||||
}}
|
||||
placeholder="Type your message..."
|
||||
className="field-sizing-content resize-none border-none bg-transparent p-3.5 pb-0 shadow-none ring-0 outline-none focus:ring-0 focus:outline-none"
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between p-2 pt-4">
|
||||
<div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id="render-tool-calls"
|
||||
checked={hideToolCalls ?? false}
|
||||
onCheckedChange={setHideToolCalls}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="render-tool-calls"
|
||||
className="text-sm text-gray-600"
|
||||
>
|
||||
Hide Tool Calls
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
{stream.isLoading ? (
|
||||
<Button
|
||||
key="stop"
|
||||
onClick={() => stream.stop()}
|
||||
>
|
||||
<LoaderCircle className="h-4 w-4 animate-spin" />
|
||||
Cancel
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="submit"
|
||||
className="shadow-md transition-all"
|
||||
disabled={isLoading || !input.trim()}
|
||||
>
|
||||
Send
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</StickToBottom>
|
||||
</motion.div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</StickToBottom>
|
||||
</motion.div>
|
||||
</ArtifactLayout>
|
||||
</Artifact.Context>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,6 +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";
|
||||
|
||||
function CustomComponent({
|
||||
message,
|
||||
@@ -21,6 +22,7 @@ function CustomComponent({
|
||||
message: Message;
|
||||
thread: ReturnType<typeof useStreamContext>;
|
||||
}) {
|
||||
const artifact = Artifact.useArtifact();
|
||||
const { values } = useStreamContext();
|
||||
const customComponents = values.ui?.filter(
|
||||
(ui) => ui.metadata?.message_id === message.id,
|
||||
@@ -30,12 +32,13 @@ function CustomComponent({
|
||||
return (
|
||||
<Fragment key={message.id}>
|
||||
{customComponents.map((customComponent) => (
|
||||
<LoadExternalComponent
|
||||
key={customComponent.id}
|
||||
stream={thread}
|
||||
message={customComponent}
|
||||
meta={{ ui: customComponent }}
|
||||
/>
|
||||
<Fragment key={customComponent.id}>
|
||||
<LoadExternalComponent
|
||||
stream={thread}
|
||||
message={customComponent}
|
||||
meta={{ ui: customComponent, artifact }}
|
||||
/>
|
||||
</Fragment>
|
||||
))}
|
||||
</Fragment>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user