Studio: HTML canvas cards in chat with auto-render, a Code view, and visible diffusion code (#6374)

* Studio: auto-render fenced HTML in chat replies as canvas cards

After an assistant reply finishes, append a clickable canvas card for each fenced html block in its text, with no render_html tool call and no extra message. Fragments and non-collapsed documents are covered; full documents already collapsed in place and blocks rendered by the render_html tool are skipped so nothing shows twice.

Hoists the fence helpers out of markdown-text.tsx into a shared module (html-fences.ts) and adds a line-based multi-fence scanner so several html blocks in one reply are all found.

* Studio: add HTML Code button to canvas cards and keep diffusion code visible

When Canvas mode is on or a diffusion model is loaded, the canvas card shows a Preview and an HTML Code button side by side; Code opens the panel source view. The requested view is threaded through openArtifact so the surface opens to preview or code.

Diffusion no longer collapses its full HTML answer, so the raw code stays in the message and the trailing canvas card is appended next to it.

* Studio: drop the Code button on diffusion cards since their code is already inline

* Studio: address review feedback on HTML canvas auto-cards

- Build the fence-body indent regex once per fence instead of per line.
- Only skip full-doc fences the in-place collapse can render (plain
  unindented triple-backtick), so 4-backtick or indented docs still get a card.
- Scan each text part on its own so a fence cannot stitch across a tool,
  source, or reasoning part.
- Exclude diffusion replies from the collapse/skip gates and the card Code
  button, since diffusion keeps its HTML inline.

---------

Co-authored-by: Daniel Han <michaelhan2050@gmail.com>
This commit is contained in:
Daniel Han 2026-06-16 17:38:42 -07:00 committed by GitHub
commit e1eaf6e202
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 238 additions and 114 deletions

View file

@ -4,6 +4,13 @@
"use client";
import { ArtifactCard, useChatRuntimeStore } from "@/features/chat";
import {
getCodeFence,
isFullHtmlDocument,
isHtmlFence,
isRenderableRenderHtmlToolPart,
isSvgFence,
} from "@/features/chat/artifacts/html-fences";
import { copyToClipboard } from "@/lib/copy-to-clipboard";
import { preprocessLaTeX } from "@/lib/latex";
import { openLink } from "@/lib/open-link";
@ -45,62 +52,16 @@ const STREAMDOWN_COMPONENTS = {
};
const COPY_RESET_MS = 2000;
const MERMAID_SOURCE_RE = /```mermaid\s*([\s\S]*?)```/i;
const CODE_FENCE_RE = /^```([^\r\n`]*)\r?\n([\s\S]*?)\r?\n?```$/;
const ACTION_PANEL_CLASS =
"pointer-events-auto flex shrink-0 items-center gap-1";
const ACTION_BUTTON_CLASS =
"flex size-8 cursor-pointer items-center justify-center rounded-[10px] text-chat-icon-fg transition-all hover:bg-chat-icon-bg-hover hover:text-chat-icon-fg-hover disabled:cursor-not-allowed disabled:opacity-50";
type CodeFence = {
language: string | null;
source: string;
};
type ToolCallPartLike = {
type?: string;
toolName?: string;
args?: unknown;
result?: unknown;
};
function isRenderableRenderHtmlToolPart(part: unknown): boolean {
const toolPart = part as ToolCallPartLike;
if (toolPart.type !== "tool-call" || toolPart.toolName !== "render_html") {
return false;
}
if (
typeof toolPart.result === "string" &&
toolPart.result.startsWith("Error:")
) {
return false;
}
if (
typeof toolPart.result === "string" &&
toolPart.result.startsWith("Rendered HTML canvas")
) {
return true;
}
const args = toolPart.args as { code?: unknown } | undefined;
return typeof args?.code === "string" && args.code.trim().length > 0;
}
function getMermaidSource(blockContent: string): string | null {
const source = blockContent.match(MERMAID_SOURCE_RE)?.[1]?.trim();
return source && source.length > 0 ? source : null;
}
function getCodeFence(blockContent: string): CodeFence | null {
const match = blockContent.trimEnd().match(CODE_FENCE_RE);
if (!match) {
return null;
}
return {
language: match[1]?.trim() || null,
source: match[2],
};
}
function getCodeFilename(language: string | null) {
const extByLanguage: Record<string, string> = {
bash: "sh",
@ -131,28 +92,6 @@ function getCodeFilename(language: string | null) {
return `snippet.${ext}`;
}
function isSvgFence(codeFence: CodeFence): boolean {
const lang = codeFence.language?.toLowerCase() ?? "";
if (lang === "svg") return true;
if (lang === "xml" || lang === "html") {
const trimmed = codeFence.source.trimStart();
// Match <svg directly or <?xml ...?> followed by <svg
if (trimmed.startsWith("<svg")) return true;
if (trimmed.startsWith("<?xml") && trimmed.includes("<svg")) return true;
}
return false;
}
function isHtmlFence(codeFence: CodeFence): boolean {
const lang = codeFence.language?.toLowerCase() ?? "";
return lang === "html" && !isSvgFence(codeFence);
}
function isFullHtmlDocument(source: string): boolean {
const trimmed = source.trimStart();
return /^<!doctype\s+html\b/i.test(trimmed) || /^<html[\s>]/i.test(trimmed);
}
const UNSAFE_SVG_RE =
/<script[\s>]|on\w+\s*=|javascript:|<foreignObject[\s>]|<iframe[\s>]|<embed[\s>]|<object[\s>]/i;
@ -285,15 +224,13 @@ function CodeBlockActions({
);
}
// DiffusionGemma renders its denoising live in the bubble (see DiffusionCanvas in
// thread.tsx) and has the HTML canvas feature on by default, so a full-HTML answer
// (e.g. a playable game) renders as an interactive card without the global toggle.
// Collapse a full-HTML answer in place into an artifact card. Diffusion keeps the
// raw code visible instead (the trailing MessageHtmlArtifacts appends its card).
function StreamdownBlock(props: BlockProps) {
const shouldCollapseHtmlArtifacts = useChatRuntimeStore(
(state) =>
state.artifactsEnabled ||
state.collapseHtmlArtifacts ||
state.loadedIsDiffusion,
(state.artifactsEnabled || state.collapseHtmlArtifacts) &&
!state.loadedIsDiffusion,
);
const messageHasRenderableRenderHtmlTool = useAuiState(({ message }) =>
message.parts.some(isRenderableRenderHtmlToolPart),

View file

@ -11,6 +11,7 @@ import {
} from "@/components/assistant-ui/generated-image-overlay-context";
import { downloadImagePart } from "@/components/assistant-ui/image";
import { MarkdownText } from "@/components/assistant-ui/markdown-text";
import { MessageHtmlArtifacts } from "@/components/assistant-ui/message-html-artifacts";
import { MessageTiming } from "@/components/assistant-ui/message-timing";
import { Reasoning, ReasoningGroup } from "@/components/assistant-ui/reasoning";
import { RagSourcesGroup } from "@/components/assistant-ui/rag-sources";
@ -3227,6 +3228,7 @@ const AssistantMessage: FC = () => {
/>
<SourcesGroup />
<RagSourcesGroup />
<MessageHtmlArtifacts />
<MessageError />
</div>

View file

@ -3,12 +3,14 @@
"use client";
import { CodeToggleIcon } from "@/components/assistant-ui/code-toggle-icon";
import { cn } from "@/lib/utils";
import { useAuiState } from "@assistant-ui/react";
import { LayoutTwoColumnIcon as Layout2ColumnIcon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { useLayoutEffect, useMemo } from "react";
import { useChatRuntimeStore } from "../stores/chat-runtime-store";
import type { ArtifactViewMode } from "./html-frame";
import {
hasAutoOpenedArtifact,
rememberAutoOpenedArtifact,
@ -20,6 +22,9 @@ import {
createChatArtifact,
} from "./types";
const CARD_BASE =
"group/artifact-card relative flex min-h-[52px] 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";
export function ArtifactCard({
code,
title,
@ -40,6 +45,11 @@ export function ArtifactCard({
isStreaming?: boolean;
}) {
const activeThreadId = useChatRuntimeStore((state) => state.activeThreadId);
// Canvas mode collapses the raw code in place, so offer a Code button too.
// Diffusion keeps its code inline, so it needs no Code button.
const showCodeButton = useChatRuntimeStore(
(state) => state.artifactsEnabled && !state.loadedIsDiffusion,
);
const messageIdFromContext = useAuiState(({ message }) => message.id);
const threadIdFromContext = useAuiState(
({ threads }) => threads.mainThreadId,
@ -87,7 +97,7 @@ export function ArtifactCard({
}
rememberAutoOpenedArtifact(artifact.id);
openArtifact(artifact, { surface });
openArtifact(artifact, { surface, view: "preview" });
}, [
artifact,
autoOpen,
@ -97,45 +107,63 @@ export function ArtifactCard({
updateArtifact,
]);
return (
<button
type="button"
className={cn(
"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",
isStreaming &&
"border-border/80 bg-muted/20 dark:border-border/70 dark:bg-muted/15",
className,
)}
onClick={() => openArtifact(artifact, { surface })}
aria-label={`Open ${artifact.title}`}
>
{isStreaming ? (
<span
aria-hidden={true}
className="artifact-card-shimmer pointer-events-none absolute inset-0 z-0 motion-reduce:hidden"
/>
) : null}
<div className="relative z-10 flex min-w-0 flex-1 items-center gap-2.5">
<HugeiconsIcon
icon={Layout2ColumnIcon}
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 canvas
</span>
</span>
const renderButton = (view: ArtifactViewMode) => {
const isCode = view === "source";
return (
<button
key={view}
type="button"
className={cn(
CARD_BASE,
showCodeButton ? "min-w-0 flex-1" : "w-full max-w-md",
isStreaming &&
"border-border/80 bg-muted/20 dark:border-border/70 dark:bg-muted/15",
)}
onClick={() => openArtifact(artifact, { surface, view })}
aria-label={`Open ${artifact.title} ${isCode ? "code" : "preview"}`}
>
{isStreaming ? (
<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>
<span
aria-hidden={true}
className="artifact-card-shimmer pointer-events-none absolute inset-0 z-0 motion-reduce:hidden"
/>
) : null}
</div>
</button>
<div className="relative z-10 flex min-w-0 flex-1 items-center gap-2.5">
{isCode ? (
<CodeToggleIcon className="size-5 shrink-0 text-muted-foreground" />
) : (
<HugeiconsIcon
icon={Layout2ColumnIcon}
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">
{isCode ? "HTML Code" : artifact.title}
</span>
<span className="truncate text-[11px] leading-none text-muted-foreground">
HTML canvas
</span>
</span>
{isStreaming && !isCode ? (
<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>
) : null}
</div>
</button>
);
};
if (!showCodeButton) {
return <div className={cn("my-2", className)}>{renderButton("preview")}</div>;
}
return (
<div className={cn("my-2 flex w-full max-w-xl gap-2", className)}>
{renderButton("preview")}
{renderButton("source")}
</div>
);
}

View file

@ -31,6 +31,7 @@ import {
} from "react";
import { Streamdown } from "streamdown";
import { ArtifactHtmlFrame, type ArtifactViewMode } from "./html-frame";
import { useChatArtifactsStore } from "./store";
import type { ChatArtifact } from "./types";
import { getArtifactFilename } from "./types";
@ -119,6 +120,8 @@ export function ArtifactSurface({
onOpenFullscreen?: () => void;
}) {
const [viewMode, setViewMode] = useState<ArtifactViewMode>("preview");
// Follow the view the opener asked for (Preview vs Code button), per artifact.
const requestedView = useChatArtifactsStore((state) => state.requestedView);
const [copied, setCopied] = useState(false);
const copyResetRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const surfaceRef = useRef<HTMLElement>(null);
@ -138,6 +141,10 @@ export function ArtifactSurface({
};
}, []);
useEffect(() => {
setViewMode(requestedView);
}, [artifact.id, requestedView]);
useEffect(() => {
if (variant !== "overlay") return;
previousFocusRef.current = document.activeElement;

View file

@ -0,0 +1,145 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
// Shared fenced-code helpers for the HTML-artifact render paths, hoisted from
// markdown-text.tsx so the in-place collapse and the post-message auto-render
// agree on what counts as a renderable HTML fence.
export type CodeFence = {
language: string | null;
source: string;
};
// Matches one fenced block spanning the whole string (one pre-split block).
export const CODE_FENCE_RE = /^```([^\r\n`]*)\r?\n([\s\S]*?)\r?\n?```$/;
export type ToolCallPartLike = {
type?: string;
toolName?: string;
args?: unknown;
result?: unknown;
};
// True when a part is a render_html tool call with usable code or a non-error result.
export function isRenderableRenderHtmlToolPart(part: unknown): boolean {
const toolPart = part as ToolCallPartLike;
if (toolPart.type !== "tool-call" || toolPart.toolName !== "render_html") {
return false;
}
if (
typeof toolPart.result === "string" &&
toolPart.result.startsWith("Error:")
) {
return false;
}
if (
typeof toolPart.result === "string" &&
toolPart.result.startsWith("Rendered HTML canvas")
) {
return true;
}
const args = toolPart.args as { code?: unknown } | undefined;
return typeof args?.code === "string" && args.code.trim().length > 0;
}
export function getCodeFence(blockContent: string): CodeFence | null {
const match = blockContent.trimEnd().match(CODE_FENCE_RE);
if (!match) {
return null;
}
return {
language: match[1]?.trim() || null,
source: match[2],
};
}
export function isSvgFence(codeFence: CodeFence): boolean {
const lang = codeFence.language?.toLowerCase() ?? "";
if (lang === "svg") return true;
if (lang === "xml" || lang === "html") {
const trimmed = codeFence.source.trimStart();
// <svg directly, or <?xml ...?> then <svg
if (trimmed.startsWith("<svg")) return true;
if (trimmed.startsWith("<?xml") && trimmed.includes("<svg")) return true;
}
return false;
}
export function isHtmlFence(codeFence: CodeFence): boolean {
const lang = codeFence.language?.toLowerCase() ?? "";
return lang === "html" && !isSvgFence(codeFence);
}
export function isFullHtmlDocument(source: string): boolean {
const trimmed = source.trimStart();
return /^<!doctype\s+html\b/i.test(trimmed) || /^<html[\s>]/i.test(trimmed);
}
export interface HtmlFence {
source: string;
isFullDocument: boolean;
// Plain 3-backtick unindented fence: the only form the in-place collapser
// (CODE_FENCE_RE) recognizes, so only these may be skipped as already shown.
isPlainFence: boolean;
index: number;
}
// Opening fence: up to 3 leading spaces, >=3 backticks, then a backtick-free info string.
const FENCE_OPEN_RE = /^( {0,3})(`{3,})([^`\r\n]*)$/;
// Scan a full message for every closed ```html fence. Line-based so multiple
// fences are found and backticks inside a <script> string never split a block
// (a close must be its own fence line). Drops unterminated/SVG fences.
export function extractHtmlFences(text: string): HtmlFence[] {
const lines = text.split(/\r?\n/);
const fences: HtmlFence[] = [];
let i = 0;
let index = 0;
while (i < lines.length) {
const open = lines[i].match(FENCE_OPEN_RE);
if (!open) {
i++;
continue;
}
const indent = open[1].length;
const ticks = open[2].length;
const lang = open[3].trim().split(/\s+/)[0]?.toLowerCase() ?? "";
const closeRe = new RegExp(`^ {0,3}\`{${ticks},}\\s*$`);
// Strip up to `indent` leading spaces (CommonMark fence indentation).
const indentRe = indent > 0 ? new RegExp(`^ {0,${indent}}`) : null;
let j = i + 1;
const body: string[] = [];
let closed = false;
while (j < lines.length) {
if (closeRe.test(lines[j])) {
closed = true;
break;
}
body.push(indentRe ? lines[j].replace(indentRe, "") : lines[j]);
j++;
}
if (!closed) {
break; // everything after an unterminated open fence is inside it
}
if (lang === "html") {
const source = body.join("\n");
if (!isSvgFence({ language: "html", source })) {
fences.push({
source,
isFullDocument: isFullHtmlDocument(source),
isPlainFence: indent === 0 && ticks === 3,
index: index++,
});
}
}
i = j + 1;
}
return fences;
}

View file

@ -2,6 +2,7 @@
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { create } from "zustand";
import type { ArtifactViewMode } from "./html-frame";
import type { ChatArtifact, ChatArtifactSurface } from "./types";
const autoOpenedArtifactIds = new Set<string>();
@ -22,9 +23,11 @@ type ChatArtifactsState = {
artifactsById: Record<string, ChatArtifact>;
selectedArtifactId: string | null;
surface: ChatArtifactSurface;
// View the surface should show on the next open (Preview vs Code button).
requestedView: ArtifactViewMode;
openArtifact: (
artifact: ChatArtifact,
options?: { surface?: ChatArtifactSurface },
options?: { surface?: ChatArtifactSurface; view?: ArtifactViewMode },
) => void;
updateArtifact: (artifact: ChatArtifact) => void;
closeArtifactSurface: () => void;
@ -37,6 +40,7 @@ export const useChatArtifactsStore = create<ChatArtifactsState>((set) => ({
artifactsById: {},
selectedArtifactId: null,
surface: "panel",
requestedView: "preview",
openArtifact: (artifact, options) =>
set((state) => ({
artifactsById: {
@ -45,6 +49,7 @@ export const useChatArtifactsStore = create<ChatArtifactsState>((set) => ({
},
selectedArtifactId: artifact.id,
surface: options?.surface ?? state.surface,
requestedView: options?.view ?? "preview",
})),
updateArtifact: (artifact) =>
set((state) =>