Studio: polish chat artifact UI affordances
This commit is contained in:
parent
bc0ccc6768
commit
e8faa3d59b
5 changed files with 148 additions and 228 deletions
|
|
@ -8,6 +8,7 @@ import {
|
|||
type FC,
|
||||
type PropsWithChildren,
|
||||
} from "react";
|
||||
import { useAuiState } from "@assistant-ui/react";
|
||||
import { ChevronDownIcon, LoaderIcon } from "lucide-react";
|
||||
import { Wrench01Icon } from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
|
|
@ -27,7 +28,8 @@ const toolGroupVariants = cva("aui-tool-group-root group/tool-group w-full", {
|
|||
variant: {
|
||||
outline: "corner-squircle rounded-lg border py-3",
|
||||
ghost: "rounded-lg bg-muted/10 py-2",
|
||||
muted: "corner-squircle rounded-lg border border-muted-foreground/30 bg-muted/30 py-3",
|
||||
muted:
|
||||
"corner-squircle rounded-lg border border-muted-foreground/30 bg-muted/30 py-3",
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: "ghost" },
|
||||
|
|
@ -209,9 +211,17 @@ const ToolGroupImpl: FC<
|
|||
PropsWithChildren<{ startIndex: number; endIndex: number }>
|
||||
> = ({ children, startIndex, endIndex }) => {
|
||||
const toolCount = endIndex - startIndex + 1;
|
||||
const containsArtifactTool = useAuiState(({ message }) =>
|
||||
message.parts
|
||||
.slice(startIndex, endIndex + 1)
|
||||
.some(
|
||||
(part) => part.type === "tool-call" && part.toolName === "render_html",
|
||||
),
|
||||
);
|
||||
|
||||
// Single tool call — render directly without wrapper
|
||||
if (toolCount <= 1) {
|
||||
// Single tool calls and artifacts render directly so cards never hide inside
|
||||
// a collapsed tool group.
|
||||
if (toolCount <= 1 || containsArtifactTool) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@
|
|||
"use client";
|
||||
|
||||
import { ArtifactCard } from "@/features/chat";
|
||||
import { BrowserIcon } from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import {
|
||||
type ToolCallMessagePartComponent,
|
||||
useToolArgsStatus,
|
||||
|
|
@ -17,19 +19,8 @@ type RenderHtmlArgs = Record<string, unknown> & {
|
|||
title?: string;
|
||||
};
|
||||
|
||||
function formatToolResult(result: unknown): string {
|
||||
if (typeof result === "string") return result;
|
||||
if (result == null) return "";
|
||||
try {
|
||||
return JSON.stringify(result, null, 2);
|
||||
} catch {
|
||||
return String(result);
|
||||
}
|
||||
}
|
||||
|
||||
const RenderHtmlToolUIImpl: ToolCallMessagePartComponent = ({
|
||||
args,
|
||||
result,
|
||||
status,
|
||||
toolCallId,
|
||||
}) => {
|
||||
|
|
@ -39,7 +30,6 @@ const RenderHtmlToolUIImpl: ToolCallMessagePartComponent = ({
|
|||
const hasCode = code.trim().length > 0;
|
||||
const title =
|
||||
typeof parsedArgs.title === "string" ? parsedArgs.title : "HTML artifact";
|
||||
const resultText = formatToolResult(result);
|
||||
const isRunning = status?.type === "running";
|
||||
const codeIsStreaming = propStatus.code === "streaming";
|
||||
|
||||
|
|
@ -57,26 +47,25 @@ const RenderHtmlToolUIImpl: ToolCallMessagePartComponent = ({
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="my-3 overflow-hidden rounded-xl border border-border/80 bg-background/80 px-3 py-2.5 shadow-sm shadow-black/5 dark:bg-muted/10 dark:shadow-black/20">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-semibold text-foreground">
|
||||
Generating artifact…
|
||||
</p>
|
||||
<p className="mt-0.5 truncate text-xs text-muted-foreground">
|
||||
{resultText || "Waiting for HTML source"}
|
||||
</p>
|
||||
</div>
|
||||
<span className="shrink-0 rounded-full bg-primary/10 px-2 py-0.5 text-[10px] font-medium text-primary">
|
||||
<div className="relative my-2 flex min-h-[52px] w-full max-w-md items-center overflow-hidden rounded-lg border border-border/70 bg-muted/15 px-3 py-2 text-left dark:bg-muted/10">
|
||||
<div className="relative z-10 flex min-w-0 flex-1 items-center gap-2.5">
|
||||
<HugeiconsIcon
|
||||
icon={BrowserIcon}
|
||||
strokeWidth={1.75}
|
||||
className="size-5 shrink-0 text-muted-foreground"
|
||||
/>
|
||||
<span className="grid min-w-0 flex-1 gap-1">
|
||||
<span className="truncate text-sm font-medium leading-tight text-foreground">
|
||||
Generating artifact
|
||||
</span>
|
||||
<span className="truncate text-[11px] leading-none text-muted-foreground">
|
||||
HTML artifact
|
||||
</span>
|
||||
</span>
|
||||
<span className="shimmer shrink-0 rounded-full bg-primary/10 px-2 py-0.5 text-[10px] font-medium text-primary motion-reduce:animate-none">
|
||||
Generating
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="mt-2 h-1.5 overflow-hidden rounded-full bg-muted"
|
||||
aria-hidden={true}
|
||||
>
|
||||
<div className="h-full w-1/2 rounded-full bg-primary/25 shimmer motion-reduce:animate-none" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,54 +1,54 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import type * as React from "react";
|
||||
import * as ResizablePrimitive from "react-resizable-panels";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function ResizablePanelGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.Group>): React.ReactElement {
|
||||
return (
|
||||
<ResizablePrimitive.Group
|
||||
data-slot="resizable-panel-group"
|
||||
className={cn(
|
||||
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ResizablePanel({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.Panel>): React.ReactElement {
|
||||
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />;
|
||||
}
|
||||
|
||||
function ResizableHandle({
|
||||
withHandle,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.Separator> & {
|
||||
withHandle?: boolean;
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<ResizablePrimitive.Separator
|
||||
data-slot="resizable-handle"
|
||||
className={cn(
|
||||
"bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{withHandle && (
|
||||
<div className="bg-border h-6 w-1 rounded-lg z-10 flex shrink-0" />
|
||||
)}
|
||||
</ResizablePrimitive.Separator>
|
||||
);
|
||||
}
|
||||
|
||||
export { ResizablePanelGroup, ResizablePanel, ResizableHandle };
|
||||
import type * as React from "react";
|
||||
import * as ResizablePrimitive from "react-resizable-panels";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function ResizablePanelGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.Group>): React.ReactElement {
|
||||
return (
|
||||
<ResizablePrimitive.Group
|
||||
data-slot="resizable-panel-group"
|
||||
className={cn(
|
||||
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ResizablePanel({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.Panel>): React.ReactElement {
|
||||
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />;
|
||||
}
|
||||
|
||||
function ResizableHandle({
|
||||
withHandle,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.Separator> & {
|
||||
withHandle?: boolean;
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<ResizablePrimitive.Separator
|
||||
data-slot="resizable-handle"
|
||||
className={cn(
|
||||
"group bg-border/80 relative z-10 flex w-px cursor-col-resize items-center justify-center transition-[background-color,box-shadow] duration-150 ease-out after:absolute after:inset-y-0 after:left-1/2 after:w-2 after:-translate-x-1/2 hover:bg-primary/80 hover:shadow-[0_0_16px_rgba(23,184,139,0.55)] active:bg-primary/90 active:shadow-[0_0_18px_rgba(23,184,139,0.7)] focus-visible:bg-primary/80 focus-visible:ring-1 focus-visible:ring-primary/50 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:cursor-row-resize data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-2 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{withHandle && (
|
||||
<div className="z-10 flex h-6 w-1 shrink-0 rounded-lg bg-border transition-[background-color,box-shadow,transform] duration-150 ease-out group-hover:scale-y-110 group-hover:bg-primary/80 group-hover:shadow-[0_0_12px_rgba(23,184,139,0.65)] group-active:bg-primary" />
|
||||
)}
|
||||
</ResizablePrimitive.Separator>
|
||||
);
|
||||
}
|
||||
|
||||
export { ResizablePanelGroup, ResizablePanel, ResizableHandle };
|
||||
|
|
|
|||
|
|
@ -3,11 +3,11 @@
|
|||
|
||||
"use client";
|
||||
|
||||
import { copyToClipboard } from "@/lib/copy-to-clipboard";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { BrowserIcon } from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { useAuiState } from "@assistant-ui/react";
|
||||
import { CheckIcon, CopyIcon, DownloadIcon } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useChatRuntimeStore } from "../stores/chat-runtime-store";
|
||||
import {
|
||||
hasAutoOpenedArtifact,
|
||||
|
|
@ -18,49 +18,8 @@ import {
|
|||
type ChatArtifact,
|
||||
type ChatArtifactSource,
|
||||
createChatArtifact,
|
||||
getArtifactFilename,
|
||||
} from "./types";
|
||||
|
||||
const COPY_RESET_MS = 2000;
|
||||
|
||||
function downloadTextFile(filename: string, text: string): void {
|
||||
const blob = new Blob([text], { type: "text/html;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = filename;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
document.body.removeChild(anchor);
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 0);
|
||||
}
|
||||
|
||||
function useCopiedState() {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const resetTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (resetTimeoutRef.current) clearTimeout(resetTimeoutRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const showCopied = () => {
|
||||
setCopied(true);
|
||||
if (resetTimeoutRef.current) clearTimeout(resetTimeoutRef.current);
|
||||
resetTimeoutRef.current = setTimeout(() => {
|
||||
setCopied(false);
|
||||
resetTimeoutRef.current = null;
|
||||
}, COPY_RESET_MS);
|
||||
};
|
||||
|
||||
return { copied, showCopied };
|
||||
}
|
||||
|
||||
function artifactDisplayTitle(title: string): string {
|
||||
return /artifact/i.test(title) ? title : `${title} Artifact`;
|
||||
}
|
||||
|
||||
export function ArtifactCard({
|
||||
code,
|
||||
title,
|
||||
|
|
@ -91,7 +50,6 @@ export function ArtifactCard({
|
|||
const selectedArtifactId = useChatArtifactsStore(
|
||||
(state) => state.selectedArtifactId,
|
||||
);
|
||||
const { copied, showCopied } = useCopiedState();
|
||||
const artifact = useMemo<ChatArtifact>(
|
||||
() =>
|
||||
createChatArtifact({
|
||||
|
|
@ -114,10 +72,7 @@ export function ArtifactCard({
|
|||
title,
|
||||
],
|
||||
);
|
||||
const filename = getArtifactFilename(artifact);
|
||||
const surface = artifactThreadId ? "panel" : "overlay";
|
||||
const lineCount = artifact.code.split("\n").length;
|
||||
const displayTitle = artifactDisplayTitle(artifact.title);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoOpen) return;
|
||||
|
|
@ -139,71 +94,36 @@ export function ArtifactCard({
|
|||
]);
|
||||
|
||||
return (
|
||||
<div
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"my-3 cursor-pointer overflow-hidden rounded-xl border border-border/80 bg-background/80 shadow-sm shadow-black/5 transition-colors hover:bg-muted/30",
|
||||
"dark:bg-muted/10 dark:shadow-black/20 dark:hover:bg-muted/20",
|
||||
"group/artifact-card relative my-2 flex min-h-[52px] w-full max-w-md cursor-pointer items-center overflow-hidden rounded-lg border border-border/70 bg-muted/15 px-3 py-2 text-left transition-colors hover:bg-muted/25 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
"dark:bg-muted/10 dark:hover:bg-muted/20",
|
||||
className,
|
||||
)}
|
||||
onClick={() => openArtifact(artifact, { surface })}
|
||||
aria-label={`Open ${artifact.title}`}
|
||||
>
|
||||
<div className="flex items-center gap-3 px-3 py-2.5">
|
||||
<button
|
||||
type="button"
|
||||
className="min-w-0 flex-1 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
openArtifact(artifact, { surface });
|
||||
}}
|
||||
aria-label={`Open ${displayTitle}`}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<p className="truncate text-sm font-semibold text-foreground">
|
||||
{displayTitle}
|
||||
</p>
|
||||
{isStreaming ? (
|
||||
<span className="shrink-0 rounded-full bg-primary/10 px-2 py-0.5 text-[10px] font-medium text-primary">
|
||||
Generating
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
HTML artifact ·{" "}
|
||||
{source === "tool" ? "tool call" : "fenced fallback"} · {lineCount}{" "}
|
||||
lines
|
||||
</p>
|
||||
</button>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-8 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
title="Copy HTML"
|
||||
aria-label="Copy artifact HTML"
|
||||
onClick={async (event) => {
|
||||
event.stopPropagation();
|
||||
if (await copyToClipboard(artifact.code)) showCopied();
|
||||
}}
|
||||
>
|
||||
{copied ? (
|
||||
<CheckIcon className="size-4" />
|
||||
) : (
|
||||
<CopyIcon className="size-4" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-8 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
title="Download HTML"
|
||||
aria-label="Download artifact HTML"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
downloadTextFile(filename, artifact.code);
|
||||
}}
|
||||
>
|
||||
<DownloadIcon className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="relative z-10 flex min-w-0 flex-1 items-center gap-2.5">
|
||||
<HugeiconsIcon
|
||||
icon={BrowserIcon}
|
||||
strokeWidth={1.75}
|
||||
className="size-5 shrink-0 text-muted-foreground"
|
||||
/>
|
||||
<span className="grid min-w-0 flex-1 gap-1">
|
||||
<span className="truncate text-sm font-medium leading-tight text-foreground">
|
||||
{artifact.title}
|
||||
</span>
|
||||
<span className="truncate text-[11px] leading-none text-muted-foreground">
|
||||
HTML artifact
|
||||
</span>
|
||||
</span>
|
||||
{isStreaming ? (
|
||||
<span className="shrink-0 rounded-full bg-primary/10 px-2 py-0.5 text-[10px] font-medium text-primary">
|
||||
Generating
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
"use client";
|
||||
|
||||
import { createCodePlugin } from "@/components/assistant-ui/code-plugin";
|
||||
import { CodeToggleIcon } from "@/components/assistant-ui/code-toggle-icon";
|
||||
import {
|
||||
unslothDarkTheme,
|
||||
unslothLightTheme,
|
||||
|
|
@ -15,7 +16,7 @@ import {
|
|||
CheckIcon,
|
||||
CopyIcon,
|
||||
DownloadIcon,
|
||||
FileTextIcon,
|
||||
EyeIcon,
|
||||
Maximize2Icon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
|
|
@ -176,19 +177,42 @@ export function ArtifactSurface({
|
|||
)}
|
||||
aria-label={`${artifact.title} artifact`}
|
||||
>
|
||||
<header className="flex shrink-0 items-center gap-3 border-b border-border px-3 py-2">
|
||||
<div className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
<FileTextIcon className="size-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-semibold text-foreground">
|
||||
{artifact.title}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
HTML artifact ·{" "}
|
||||
{artifact.source === "tool" ? "tool call" : "fenced fallback"}
|
||||
{artifact.isStreaming ? " · streaming" : ""}
|
||||
</p>
|
||||
<header className="flex shrink-0 items-center justify-between gap-3 border-b border-border px-2.5 py-2">
|
||||
<div
|
||||
className="flex items-center gap-1 rounded-md bg-muted/40 p-0.5"
|
||||
role="tablist"
|
||||
aria-label="Artifact view"
|
||||
>
|
||||
{(["preview", "source"] as const).map((mode) => {
|
||||
const isPreview = mode === "preview";
|
||||
const Icon = isPreview ? EyeIcon : CodeToggleIcon;
|
||||
return (
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
role="tab"
|
||||
disabled={artifact.isStreaming && isPreview}
|
||||
onClick={() => setViewMode(mode)}
|
||||
className={cn(
|
||||
"flex size-8 items-center justify-center rounded-[4px] text-muted-foreground transition-colors",
|
||||
effectiveViewMode === mode
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "hover:bg-background/70 hover:text-foreground",
|
||||
artifact.isStreaming &&
|
||||
isPreview &&
|
||||
"cursor-not-allowed opacity-50",
|
||||
)}
|
||||
aria-label={
|
||||
isPreview ? "Preview artifact" : "View artifact source"
|
||||
}
|
||||
aria-selected={effectiveViewMode === mode}
|
||||
aria-pressed={effectiveViewMode === mode}
|
||||
title={isPreview ? "Preview" : "Source"}
|
||||
>
|
||||
<Icon className="size-4" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button
|
||||
|
|
@ -240,29 +264,6 @@ export function ArtifactSurface({
|
|||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-1 border-b border-border px-3 py-2">
|
||||
{(["preview", "source"] as const).map((mode) => (
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
disabled={artifact.isStreaming && mode === "preview"}
|
||||
onClick={() => setViewMode(mode)}
|
||||
className={cn(
|
||||
"rounded-lg px-3 py-1.5 text-xs font-medium transition-colors",
|
||||
effectiveViewMode === mode
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
artifact.isStreaming &&
|
||||
mode === "preview" &&
|
||||
"cursor-not-allowed opacity-50",
|
||||
)}
|
||||
aria-pressed={effectiveViewMode === mode}
|
||||
>
|
||||
{mode === "preview" ? "Preview" : "Source"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-hidden bg-background">
|
||||
{effectiveViewMode === "preview" ? (
|
||||
<ArtifactHtmlFrame
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue