Studio: polish chat artifact UI affordances

This commit is contained in:
wasimysaid 2026-05-25 22:57:10 +02:00
commit e8faa3d59b
5 changed files with 148 additions and 228 deletions

View file

@ -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}</>;
}

View file

@ -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>
);
};

View file

@ -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 };

View file

@ -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>
);
}

View file

@ -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