Studio: fix chat artifact panel and sandbox previews

This commit is contained in:
wasimysaid 2026-05-25 21:10:58 +02:00
commit b6faf7ffcd
11 changed files with 342 additions and 129 deletions

View file

@ -312,6 +312,7 @@ from starlette.requests import Request as _StarletteRequest # noqa: E402
_CSP_SCRIPT_NONCE_HEADER = "x-internal-script-nonce"
_ARTIFACT_PREVIEW_FRAME_PATH = "/api/inference/artifact-preview-frame"
def _build_csp(script_nonce: "str | None" = None) -> str:
@ -327,6 +328,7 @@ def _build_csp(script_nonce: "str | None" = None) -> str:
"style-src 'self' 'unsafe-inline'; "
f"{script_src}; "
"font-src 'self' data:; "
"frame-src 'self'; "
"frame-ancestors 'none'; "
"form-action 'self'; "
"base-uri 'self'"
@ -343,7 +345,8 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
if nonce is not None:
del response.headers[_CSP_SCRIPT_NONCE_HEADER]
response.headers.setdefault("Content-Security-Policy", _build_csp(nonce))
response.headers.setdefault("X-Frame-Options", "DENY")
if request.url.path != _ARTIFACT_PREVIEW_FRAME_PATH:
response.headers.setdefault("X-Frame-Options", "DENY")
response.headers.setdefault("X-Content-Type-Options", "nosniff")
response.headers.setdefault("Referrer-Policy", "no-referrer")
response.headers.setdefault(

View file

@ -239,6 +239,58 @@ router = APIRouter()
studio_router = APIRouter()
_ARTIFACT_PREVIEW_FRAME_CSP = (
"default-src 'none'; "
"script-src 'unsafe-inline'; "
"style-src 'unsafe-inline'; "
"img-src data: blob:; "
"font-src data:; "
"media-src data: blob:; "
"connect-src 'none'; "
"object-src 'none'; "
"base-uri 'none'; "
"form-action 'none'; "
"sandbox allow-scripts"
)
_ARTIFACT_PREVIEW_FRAME_HTML = """<!doctype html>
<html>
<head><meta charset=\"utf-8\" /></head>
<body>
<script>
(() => {
const render = (html) => {
document.open();
document.write(html);
document.close();
};
window.addEventListener("message", (event) => {
const data = event.data;
if (!data || data.type !== "unsloth:artifact-html" || typeof data.html !== "string") return;
render(data.html);
});
parent.postMessage({ chatArtifactReady: true }, "*");
})();
</script>
</body>
</html>"""
@studio_router.get("/artifact-preview-frame", include_in_schema=False)
async def artifact_preview_frame():
"""Serve the opaque sandbox shell used for client-side HTML artifacts."""
return Response(
content=_ARTIFACT_PREVIEW_FRAME_HTML,
media_type="text/html; charset=utf-8",
headers={
"Cache-Control": "no-store",
"Content-Security-Policy": _ARTIFACT_PREVIEW_FRAME_CSP,
"Referrer-Policy": "no-referrer",
"X-Content-Type-Options": "nosniff",
},
)
def _detect_safetensors_features(backend, chat_template: Optional[str]) -> dict:
"""Classify reasoning/tool capabilities via the GGUF classifier so
flags match across backends. gpt-oss is overridden because Harmony
@ -419,15 +471,23 @@ async def _await_cancel_then_close(cancel_event, resp) -> None:
return
# Appended to tool-use nudge to discourage plan-without-action
# Appended to tool-use nudge to discourage plan-without-action.
# Keep render_html guidance gated to turns where the artifact tool is actually
# present in the tool schema; otherwise small local models can hallucinate a
# missing tool call instead of following the fenced-HTML fallback prompt.
_TOOL_ACTION_NUDGE = (
" IMPORTANT: Always call tools directly -- never write code yourself."
" Never describe what you plan to do -- just call the tool immediately."
" For HTML, CSS, or JavaScript artifact requests, call render_html when it is available."
" For non-artifact code requests, call the python tool when it is available."
" For factual questions that require current information, call web_search when it is available."
" Do NOT output raw code blocks when an enabled tool can satisfy the request."
)
_ARTIFACT_TOOL_ACTION_NUDGE = " For HTML, CSS, or JavaScript artifact requests, call render_html when it is available."
def _tool_action_nudge(has_artifact: bool) -> str:
return _TOOL_ACTION_NUDGE + (_ARTIFACT_TOOL_ACTION_NUDGE if has_artifact else "")
# Strip tool-call XML the speculative buffer in core/inference/llama_cpp.py
# split across the visible/DRAIN boundary. Four leak shapes:
@ -2447,7 +2507,7 @@ async def openai_chat_completions(
_nudge = ""
if _nudge:
_nudge += _TOOL_ACTION_NUDGE
_nudge += _tool_action_nudge(_has_artifact)
# Append nudge to system prompt (preserve user's prompt)
if system_prompt:
system_prompt = system_prompt.rstrip() + "\n\n" + _nudge
@ -2935,7 +2995,7 @@ async def openai_chat_completions(
_sf_system_prompt = system_prompt
if _sf_nudge:
_sf_nudge += _TOOL_ACTION_NUDGE
_sf_nudge += _tool_action_nudge(_sf_has_artifact)
if _sf_system_prompt:
_sf_system_prompt = _sf_system_prompt.rstrip() + "\n\n" + _sf_nudge
else:
@ -4614,7 +4674,7 @@ async def anthropic_messages(
_nudge = ""
if _nudge:
_nudge += _TOOL_ACTION_NUDGE
_nudge += _tool_action_nudge(_has_artifact)
# Inject into system prompt
if openai_messages and openai_messages[0].get("role") == "system":
openai_messages[0]["content"] = (

View file

@ -6,20 +6,16 @@
import { ArtifactCard } from "@/features/chat";
import {
type ToolCallMessagePartComponent,
useAuiState,
useToolArgsStatus,
} from "@assistant-ui/react";
import { FileTextIcon, LoaderIcon } from "lucide-react";
import { memo, useEffect, useState } from "react";
import {
ToolFallbackContent,
ToolFallbackRoot,
ToolFallbackTrigger,
} from "./tool-fallback";
import { memo } from "react";
interface RenderHtmlArgs {
// Context7 assistant-ui docs: tool UIs can read streaming args via
// useToolArgsStatus, so render_html does not need to wait for tool completion.
type RenderHtmlArgs = Record<string, unknown> & {
code?: string;
title?: string;
}
};
function formatToolResult(result: unknown): string {
if (typeof result === "string") return result;
@ -37,6 +33,7 @@ const RenderHtmlToolUIImpl: ToolCallMessagePartComponent = ({
status,
toolCallId,
}) => {
const { propStatus } = useToolArgsStatus<RenderHtmlArgs>();
const parsedArgs = (args as RenderHtmlArgs) ?? {};
const code = typeof parsedArgs.code === "string" ? parsedArgs.code : "";
const hasCode = code.trim().length > 0;
@ -44,50 +41,44 @@ const RenderHtmlToolUIImpl: ToolCallMessagePartComponent = ({
typeof parsedArgs.title === "string" ? parsedArgs.title : "HTML artifact";
const resultText = formatToolResult(result);
const isRunning = status?.type === "running";
const hasText = useAuiState(({ message }) =>
message.content.some(
(part) =>
part.type === "text" &&
"text" in part &&
(part as { text: string }).text.length > 0,
),
);
const [open, setOpen] = useState(true);
const codeIsStreaming = propStatus.code === "streaming";
useEffect(() => {
const nextOpen = isRunning ? true : !hasText;
const timeoutId = window.setTimeout(() => setOpen(nextOpen), 0);
return () => window.clearTimeout(timeoutId);
}, [hasText, isRunning]);
if (hasCode) {
return (
<ArtifactCard
code={code}
title={title}
source="tool"
sourceToolCallId={toolCallId}
preview={false}
autoOpen={true}
isStreaming={isRunning || codeIsStreaming}
/>
);
}
return (
<ToolFallbackRoot open={open} onOpenChange={setOpen}>
<ToolFallbackTrigger
toolName={isRunning ? "Rendering HTML artifact…" : title}
status={status}
icon={FileTextIcon}
/>
<ToolFallbackContent>
{isRunning && !hasCode ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<LoaderIcon className="size-3.5 animate-spin" />
<span>Waiting for artifact source</span>
</div>
) : hasCode ? (
<ArtifactCard
code={code}
title={title}
source="tool"
sourceToolCallId={toolCallId}
preview={false}
/>
) : (
<p className="whitespace-pre-wrap text-sm text-muted-foreground">
{resultText || "No HTML artifact source was provided."}
<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>
)}
</ToolFallbackContent>
</ToolFallbackRoot>
<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">
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

@ -6,16 +6,9 @@
import { copyToClipboard } from "@/lib/copy-to-clipboard";
import { cn } from "@/lib/utils";
import { useAuiState } from "@assistant-ui/react";
import {
CheckIcon,
CopyIcon,
DownloadIcon,
ExternalLinkIcon,
FileTextIcon,
} from "lucide-react";
import { CheckIcon, CopyIcon, DownloadIcon } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useChatRuntimeStore } from "../stores/chat-runtime-store";
import { ArtifactHtmlFrame } from "./html-frame";
import { useChatArtifactsStore } from "./store";
import {
type ChatArtifact,
@ -25,6 +18,7 @@ import {
} from "./types";
const COPY_RESET_MS = 2000;
const autoOpenedArtifactIds = new Set<string>();
function downloadTextFile(filename: string, text: string): void {
const blob = new Blob([text], { type: "text/html;charset=utf-8" });
@ -60,6 +54,10 @@ function useCopiedState() {
return { copied, showCopied };
}
function artifactDisplayTitle(title: string): string {
return /artifact/i.test(title) ? title : `${title} Artifact`;
}
export function ArtifactCard({
code,
title,
@ -67,7 +65,8 @@ export function ArtifactCard({
sourceToolCallId,
sourceMessageId,
className,
preview = true,
autoOpen = false,
isStreaming = false,
}: {
code: string;
title?: string | null;
@ -76,6 +75,8 @@ export function ArtifactCard({
sourceMessageId?: string | null;
className?: string;
preview?: boolean;
autoOpen?: boolean;
isStreaming?: boolean;
}) {
const activeThreadId = useChatRuntimeStore((state) => state.activeThreadId);
const messageIdFromContext = useAuiState(({ message }) => message.id);
@ -84,6 +85,10 @@ export function ArtifactCard({
);
const artifactThreadId = threadIdFromContext ?? activeThreadId ?? null;
const openArtifact = useChatArtifactsStore((state) => state.openArtifact);
const updateArtifact = useChatArtifactsStore((state) => state.updateArtifact);
const selectedArtifactId = useChatArtifactsStore(
(state) => state.selectedArtifactId,
);
const { copied, showCopied } = useCopiedState();
const artifact = useMemo<ChatArtifact>(
() =>
@ -94,10 +99,12 @@ export function ArtifactCard({
sourceMessageId: sourceMessageId ?? messageIdFromContext ?? null,
sourceToolCallId: sourceToolCallId ?? null,
threadId: artifactThreadId,
isStreaming,
}),
[
artifactThreadId,
code,
isStreaming,
messageIdFromContext,
source,
sourceMessageId,
@ -107,43 +114,71 @@ export function ArtifactCard({
);
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;
if (!autoOpenedArtifactIds.has(artifact.id)) {
autoOpenedArtifactIds.add(artifact.id);
openArtifact(artifact, { surface });
return;
}
if (selectedArtifactId === artifact.id) {
updateArtifact(artifact);
}
}, [
artifact,
autoOpen,
openArtifact,
selectedArtifactId,
surface,
updateArtifact,
]);
return (
<div
className={cn(
"my-3 overflow-hidden rounded-xl border border-border bg-card/70 shadow-sm",
"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",
className,
)}
onClick={() => openArtifact(artifact, { surface })}
>
<div className="flex items-center gap-3 border-b border-border/70 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-medium text-foreground">
{artifact.title}
</p>
<p className="text-xs text-muted-foreground">
<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"}
{source === "tool" ? "tool call" : "fenced fallback"} · {lineCount}{" "}
lines
</p>
</div>
</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="Open artifact"
aria-label="Open artifact"
onClick={() => openArtifact(artifact, { surface })}
>
<ExternalLinkIcon 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="Copy HTML"
aria-label="Copy artifact HTML"
onClick={async () => {
onClick={async (event) => {
event.stopPropagation();
if (await copyToClipboard(artifact.code)) showCopied();
}}
>
@ -158,17 +193,15 @@ export function ArtifactCard({
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={() => downloadTextFile(filename, artifact.code)}
onClick={(event) => {
event.stopPropagation();
downloadTextFile(filename, artifact.code);
}}
>
<DownloadIcon className="size-4" />
</button>
</div>
</div>
{preview ? (
<div className="max-h-[320px] overflow-auto bg-background">
<ArtifactHtmlFrame code={artifact.code} title={artifact.title} />
</div>
) : null}
</div>
);
}

View file

@ -3,6 +3,11 @@
"use client";
import { createCodePlugin } from "@/components/assistant-ui/code-plugin";
import {
unslothDarkTheme,
unslothLightTheme,
} from "@/components/assistant-ui/code-themes";
import { Button } from "@/components/ui/button";
import { copyToClipboard } from "@/lib/copy-to-clipboard";
import { cn } from "@/lib/utils";
@ -14,12 +19,35 @@ import {
Maximize2Icon,
XIcon,
} from "lucide-react";
import { type KeyboardEvent, useEffect, useRef, useState } from "react";
import {
type KeyboardEvent,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { Streamdown } from "streamdown";
import { ArtifactHtmlFrame, type ArtifactViewMode } from "./html-frame";
import type { ChatArtifact } from "./types";
import { getArtifactFilename } from "./types";
const COPY_RESET_MS = 2000;
const artifactSourceCodePlugin = createCodePlugin({
themes: [unslothLightTheme, unslothDarkTheme],
});
function buildHtmlFence(source: string): string {
const longestBacktickRun = Math.max(
2,
...(source.match(/`+/g) ?? []).map((match) => match.length),
);
const fence = "`".repeat(longestBacktickRun + 1);
return `${fence}html\n${source}\n${fence}`;
}
// Sandboxed artifact iframes are intentionally excluded from the overlay focus
// trap. Granting same-origin sandbox privileges would weaken isolation, so
// keyboard users can reach Studio controls here while fully interactive artifact
// content remains a known sandbox limitation.
const FOCUSABLE_SELECTOR =
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
@ -57,12 +85,20 @@ export function ArtifactSurface({
onClose: () => void;
onOpenFullscreen?: () => void;
}) {
const [viewMode, setViewMode] = useState<ArtifactViewMode>("preview");
const [viewMode, setViewMode] = useState<ArtifactViewMode>(
artifact.isStreaming ? "source" : "preview",
);
const [copied, setCopied] = useState(false);
const copyResetRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const surfaceRef = useRef<HTMLElement>(null);
const previousFocusRef = useRef<Element | null>(null);
const filename = getArtifactFilename(artifact);
const sourceMarkdown = useMemo(
() => buildHtmlFence(artifact.code),
[artifact.code],
);
const effectiveViewMode =
artifact.isStreaming && viewMode === "preview" ? "source" : viewMode;
useEffect(() => {
return () => {
@ -135,7 +171,7 @@ export function ArtifactSurface({
className={cn(
"flex min-h-0 flex-col overflow-hidden border border-border bg-background shadow-xl",
variant === "panel"
? "h-full w-full rounded-none border-y-0 border-r-0"
? "mt-[48px] h-[calc(100%_-_48px)] w-full rounded-none border-y-0 border-r-0"
: "h-[min(92vh,900px)] w-[min(96vw,1200px)] rounded-2xl",
)}
aria-label={`${artifact.title} artifact`}
@ -151,6 +187,7 @@ export function ArtifactSurface({
<p className="text-xs text-muted-foreground">
HTML artifact ·{" "}
{artifact.source === "tool" ? "tool call" : "fenced fallback"}
{artifact.isStreaming ? " · streaming" : ""}
</p>
</div>
<div className="flex shrink-0 items-center gap-1">
@ -208,14 +245,18 @@ export function ArtifactSurface({
<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",
viewMode === mode
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={viewMode === mode}
aria-pressed={effectiveViewMode === mode}
>
{mode === "preview" ? "Preview" : "Source"}
</button>
@ -223,7 +264,7 @@ export function ArtifactSurface({
</div>
<div className="min-h-0 flex-1 overflow-hidden bg-background">
{viewMode === "preview" ? (
{effectiveViewMode === "preview" ? (
<ArtifactHtmlFrame
key={artifact.id}
code={artifact.code}
@ -232,9 +273,16 @@ export function ArtifactSurface({
className="h-full"
/>
) : (
<pre className="h-full overflow-auto p-4 text-xs leading-relaxed text-foreground whitespace-pre">
<code>{artifact.code}</code>
</pre>
<div className="h-full overflow-auto text-xs leading-relaxed [&_[data-streamdown=code-block]]:!rounded-none [&_pre]:!m-0 [&_pre]:!bg-transparent [&_pre]:!p-0 [&_pre]:text-xs [&_pre]:leading-relaxed [&_code]:text-xs">
<Streamdown
mode="streaming"
plugins={{ code: artifactSourceCodePlugin }}
controls={{ code: false }}
shikiTheme={[unslothLightTheme, unslothDarkTheme]}
>
{sourceMarkdown}
</Streamdown>
</div>
)}
</div>
</section>

View file

@ -3,8 +3,10 @@
"use client";
import { apiUrl } from "@/lib/api-base";
import { cn } from "@/lib/utils";
import { useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { hashArtifactCode } from "./types";
const HTML_FRAME_DEFAULT_HEIGHT = 400;
const HTML_FRAME_MAX_HEIGHT = 900;
@ -40,11 +42,27 @@ export function ArtifactHtmlFrame({
}) {
const iframeRef = useRef<HTMLIFrameElement>(null);
const [height, setHeight] = useState(HTML_FRAME_DEFAULT_HEIGHT);
const srcDoc = useMemo(() => buildArtifactSrcDoc(code), [code]);
const artifactHtml = useMemo(() => buildArtifactSrcDoc(code), [code]);
const src = useMemo(
() =>
apiUrl(
`/api/inference/artifact-preview-frame?v=${encodeURIComponent(hashArtifactCode(code))}`,
),
[code],
);
const postArtifactHtml = useCallback(() => {
iframeRef.current?.contentWindow?.postMessage(
{ type: "unsloth:artifact-html", html: artifactHtml },
"*",
);
}, [artifactHtml]);
useEffect(() => {
const handler = (event: MessageEvent) => {
if (event.source !== iframeRef.current?.contentWindow) return;
if (event.data?.chatArtifactReady === true) {
postArtifactHtml();
}
if (typeof event.data?.chatArtifactHeight !== "number") return;
setHeight(
Math.min(
@ -55,14 +73,15 @@ export function ArtifactHtmlFrame({
};
window.addEventListener("message", handler);
return () => window.removeEventListener("message", handler);
}, []);
}, [postArtifactHtml]);
return (
<iframe
ref={iframeRef}
srcDoc={srcDoc}
src={src}
sandbox="allow-scripts"
referrerPolicy="no-referrer"
onLoad={postArtifactHtml}
className={cn("block w-full border-0 bg-background", className)}
style={{ height: fill ? "100%" : height }}
title={title}

View file

@ -12,6 +12,7 @@ type ChatArtifactsState = {
artifact: ChatArtifact,
options?: { surface?: ChatArtifactSurface },
) => void;
updateArtifact: (artifact: ChatArtifact) => void;
closeArtifactSurface: () => void;
clearArtifactsForThread: (threadId: string | null | undefined) => void;
resetArtifacts: () => void;
@ -29,8 +30,14 @@ export const useChatArtifactsStore = create<ChatArtifactsState>((set) => ({
selectedArtifactId: artifact.id,
surface: options?.surface ?? state.surface,
})),
updateArtifact: (artifact) =>
set((state) =>
state.artifactsById[artifact.id]
? { artifactsById: { [artifact.id]: artifact } }
: state,
),
closeArtifactSurface: () =>
set({ artifactsById: {}, selectedArtifactId: null }),
set({ artifactsById: {}, selectedArtifactId: null, surface: "panel" }),
clearArtifactsForThread: (threadId) =>
set((state) => {
if (!threadId) return state;

View file

@ -12,6 +12,7 @@ export interface ChatArtifact {
sourceMessageId?: string | null;
sourceToolCallId?: string | null;
threadId?: string | null;
isStreaming?: boolean;
createdAt: number;
}
@ -22,6 +23,7 @@ export interface ChatArtifactInput {
sourceMessageId?: string | null;
sourceToolCallId?: string | null;
threadId?: string | null;
isStreaming?: boolean;
}
const DEFAULT_ARTIFACT_TITLE = "HTML artifact";
@ -61,6 +63,7 @@ export function createChatArtifact(input: ChatArtifactInput): ChatArtifact {
sourceMessageId: input.sourceMessageId ?? null,
sourceToolCallId: input.sourceToolCallId ?? null,
threadId: input.threadId ?? null,
isStreaming: input.isStreaming,
createdAt: Date.now(),
};
}

View file

@ -17,13 +17,15 @@ import {
} from "@/components/ui/resizable";
import { useSidebar } from "@/components/ui/sidebar";
import { Tooltip, TooltipContent } from "@/components/ui/tooltip";
import { NativeModelChip } from "@/features/native-intents/components/native-model-chip";
import { NativeModelDropOverlay } from "@/features/native-intents/components/native-model-drop-overlay";
import { useNativeIntentStore } from "@/features/native-intents/store";
import type { NativeIntent } from "@/features/native-intents/types";
import { useChooseNativeModel } from "@/features/native-intents/use-native-dialogs";
import { useNativeModelDrop } from "@/features/native-intents/use-native-drop";
import { useNativePathLeasesSupported } from "@/features/native-intents/use-native-readiness";
import {
NativeModelChip,
NativeModelDropOverlay,
type NativeIntent,
useChooseNativeModel,
useNativeIntentStore,
useNativeModelDrop,
useNativePathLeasesSupported,
} from "@/features/native-intents";
import { GuidedTour, useGuidedTourController } from "@/features/tour";
import { isTauri } from "@/lib/api-base";
import { cn } from "@/lib/utils";
@ -179,7 +181,9 @@ const SingleContent = memo(function SingleContent({
const showArtifactPanel = Boolean(
artifact &&
artifactSurface === "panel" &&
(!artifact.threadId || !threadId || artifact.threadId === threadId),
(threadId
? !artifact.threadId || artifact.threadId === threadId
: Boolean(newThreadNonce)),
);
const threadPane = (
@ -197,21 +201,36 @@ const SingleContent = memo(function SingleContent({
{showArtifactPanel && artifact ? (
<ResizablePanelGroup
orientation="horizontal"
className="min-h-0 min-w-0 flex-1 basis-0"
className="min-h-0 min-w-0 flex-1 basis-0 overflow-hidden"
>
<ResizablePanel defaultSize={64} minSize={38}>
{threadPane}
<ResizablePanel
id="chat-thread"
defaultSize="62%"
minSize="42%"
className="h-full min-h-0 min-w-0 overflow-hidden"
>
<div className="flex h-full min-h-0 min-w-0 flex-col overflow-hidden">
{threadPane}
</div>
</ResizablePanel>
<ResizableHandle withHandle={true} />
<ResizablePanel defaultSize={36} minSize={24} maxSize={55}>
<ArtifactSurface
artifact={artifact}
variant="panel"
onClose={onCloseArtifact}
onOpenFullscreen={() =>
openArtifact(artifact, { surface: "overlay" })
}
/>
<ResizablePanel
id="chat-artifact"
defaultSize="38%"
minSize="30%"
maxSize="58%"
className="h-full min-h-0 min-w-0 overflow-hidden"
>
<div className="flex h-full min-h-0 min-w-0 flex-col overflow-hidden">
<ArtifactSurface
artifact={artifact}
variant="panel"
onClose={onCloseArtifact}
onOpenFullscreen={() =>
openArtifact(artifact, { surface: "overlay" })
}
/>
</div>
</ResizablePanel>
</ResizablePanelGroup>
) : (
@ -689,6 +708,7 @@ export function ChatPage(): ReactElement {
const modelsError = useChatRuntimeStore((state) => state.modelsError);
const modelLoading = useChatRuntimeStore((state) => state.modelLoading);
const clearCheckpoint = useChatRuntimeStore((state) => state.clearCheckpoint);
const resetArtifacts = useChatArtifactsStore((state) => state.resetArtifacts);
const activeThreadId = useChatRuntimeStore((state) => state.activeThreadId);
const modelOperationInProgress = useChatRuntimeStore(
(state) => state.modelLoading,
@ -706,6 +726,7 @@ export function ChatPage(): ReactElement {
useEffect(() => {
const turnedOff = prevConnectionsEnabledRef.current && !connectionsEnabled;
if (!connectionsEnabled && isExternalModelId(inferenceParams.checkpoint)) {
resetArtifacts();
clearCheckpoint();
if (turnedOff) {
toast.info("Connections disabled", {
@ -714,7 +735,12 @@ export function ChatPage(): ReactElement {
}
}
prevConnectionsEnabledRef.current = connectionsEnabled;
}, [clearCheckpoint, connectionsEnabled, inferenceParams.checkpoint]);
}, [
clearCheckpoint,
connectionsEnabled,
inferenceParams.checkpoint,
resetArtifacts,
]);
const pendingNativeModelIntent = useNativeIntentStore(
(state) => state.pendingModelIntent,
);
@ -939,6 +965,12 @@ export function ChatPage(): ReactElement {
closeArtifactSurface();
}, [artifactViewKey, closeArtifactSurface]);
useEffect(() => {
if (view.mode !== "single") return;
if (view.threadId || view.newThreadNonce || !selectedArtifact) return;
closeArtifactSurface();
}, [closeArtifactSurface, selectedArtifact, view]);
const hasActiveModel = Boolean(inferenceParams.checkpoint);
const loadNativeModelIntent = useCallback(
async (intent: NativeIntent, loadingDescription: string) => {
@ -1190,8 +1222,9 @@ export function ChatPage(): ReactElement {
],
);
const handleEject = useCallback(() => {
resetArtifacts();
void ejectModel();
}, [ejectModel]);
}, [ejectModel, resetArtifacts]);
const openModelSelector = useCallback(() => {
setModelSelectorLocked(true);
@ -1451,6 +1484,7 @@ export function ChatPage(): ReactElement {
const tourSteps = useMemo(
() =>
// eslint-disable-next-line react-hooks/refs -- buildChatTourSteps stores callbacks without invoking them during render.
buildChatTourSteps({
canCompare,
openModelSelector,

View file

@ -4,6 +4,7 @@
import { useEffect, useState } from "react";
import { CHAT_HISTORY_UPDATED_EVENT } from "../api/chat-api";
import { useChatRuntimeStore } from "../stores/chat-runtime-store";
import { useChatArtifactsStore } from "../artifacts/store";
import type { ThreadRecord } from "../types";
import {
deleteStoredChatThreads,
@ -157,6 +158,9 @@ export async function deleteChatItem(
// generating against a thread that no longer exists.
for (const id of threadIds) cancelIfRunning(id);
const artifactStore = useChatArtifactsStore.getState();
for (const id of threadIds) artifactStore.clearArtifactsForThread(id);
// Optimistic tombstone: hide immediately; roll back on backend error.
markChatThreadsDeleted(threadIds);
notifyChatHistoryUpdated();

View file

@ -0,0 +1,11 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
export { NativeModelChip } from "./components/native-model-chip";
export { NativeModelDropOverlay } from "./components/native-model-drop-overlay";
export { useNativeIntentStore } from "./store";
export type { NativeIntent } from "./types";
export { useChooseNativeModel } from "./use-native-dialogs";
export { useNativeModelDrop } from "./use-native-drop";
export type { NativeModelDropState } from "./use-native-drop";
export { useNativePathLeasesSupported } from "./use-native-readiness";