diff --git a/studio/frontend/src/features/chat/artifacts/artifact-surface.tsx b/studio/frontend/src/features/chat/artifacts/artifact-surface.tsx index 1955c3aca1..4e28e7f457 100644 --- a/studio/frontend/src/features/chat/artifacts/artifact-surface.tsx +++ b/studio/frontend/src/features/chat/artifacts/artifact-surface.tsx @@ -30,7 +30,7 @@ import { Streamdown } from "streamdown"; import { ArtifactHtmlFrame, type ArtifactViewMode } from "./html-frame"; import { useChatArtifactsStore } from "./store"; import type { ChatArtifact } from "./types"; -import { getArtifactFilename } from "./types"; +import { buildArtifactSourceKey, getArtifactFilename } from "./types"; const COPY_RESET_MS = 2000; const artifactSourceCodePlugin = createCodePlugin({ @@ -338,6 +338,8 @@ export function ArtifactSurface({ ) : (
>> 0).toString(36); } +// The canvas source view keys its Streamdown on this. Streamdown memoizes a code +// fence on its node's line/column span, ignoring the text, so equal-line-count +// canvases keep the old source. Tool artifact IDs omit the code, so hash it in. +export function buildArtifactSourceKey( + artifact: Pick, +): string { + return `${artifact.id}:${hashArtifactCode(artifact.code)}`; +} + export function createArtifactId(input: ChatArtifactInput): string { const threadSegment = input.threadId || "no-thread"; const messageSegment = input.sourceMessageId || "transient"; diff --git a/studio/frontend/tests/artifact-source-key.test.ts b/studio/frontend/tests/artifact-source-key.test.ts new file mode 100644 index 0000000000..e90037e603 --- /dev/null +++ b/studio/frontend/tests/artifact-source-key.test.ts @@ -0,0 +1,130 @@ +// 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 assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +import ts from "typescript"; + +import { + buildArtifactSourceKey, + createArtifactId, + createChatArtifact, + hashArtifactCode, +} from "../src/features/chat/artifacts/types.ts"; + +// The shipped helper the component keys on, not a copy of it. +const sourceKey = buildArtifactSourceKey; + +const toolInput = (code: string) => ({ + code, + source: "tool" as const, + threadId: "thread-1", + sourceMessageId: "msg-1", + sourceToolCallId: "call_0", +}); + +const fenceInput = (code: string) => ({ + code, + source: "fence" as const, + threadId: "thread-1", + sourceMessageId: "msg-1", +}); + +test("tool artifact IDs are stable across code changes, so the ID alone is not enough", () => { + const first = createArtifactId(toolInput("

first

")); + const second = createArtifactId(toolInput("

second

")); + assert.equal(first, second); +}); + +test("the source key changes when a tool artifact's code changes", () => { + const first = createChatArtifact(toolInput("

first

")); + const second = createChatArtifact(toolInput("

second

")); + assert.notEqual(sourceKey(first), sourceKey(second)); +}); + +test("the source key changes when switching between fence artifacts", () => { + const first = createChatArtifact(fenceInput("

alpha

")); + const second = createChatArtifact(fenceInput("

bravo

")); + assert.notEqual(sourceKey(first), sourceKey(second)); +}); + +test("the source key is stable for an unchanged artifact, so no needless remount", () => { + const code = "

same

"; + assert.equal( + sourceKey(createChatArtifact(toolInput(code))), + sourceKey(createChatArtifact(toolInput(code))), + ); +}); + +// Equal line count, the shape where Streamdown's comparator sees no change. +test("the source key changes for two canvases with the same shape", () => { + const first = createChatArtifact( + toolInput("\n\n

Alpha

\n\n"), + ); + const second = createChatArtifact( + toolInput("\n\n

Bravo

\n\n"), + ); + assert.equal(first.code.length, second.code.length); + assert.equal(first.code.split("\n").length, second.code.split("\n").length); + assert.notEqual(sourceKey(first), sourceKey(second)); +}); + +test("hashArtifactCode separates same-length codes and empty from whitespace", () => { + assert.notEqual(hashArtifactCode("

ab

"), hashArtifactCode("

ba

")); + assert.notEqual(hashArtifactCode(""), hashArtifactCode(" ")); +}); + +const KEYED_BY_HELPER = /^\{buildArtifactSourceKey\(\s*artifact\s*\)\}$/; + +const SURFACE_PATH = fileURLToPath( + new URL( + "../src/features/chat/artifacts/artifact-surface.tsx", + import.meta.url, + ), +); + +/** The opening tag of `node`, for both `` and ``. */ +const openingTag = (node: ts.Node): ts.JsxOpeningLikeElement | null => { + if (ts.isJsxSelfClosingElement(node)) return node; + if (ts.isJsxElement(node)) return node.openingElement; + return null; +}; + +/** The `key` expression on the source view's Streamdown, or null if unkeyed. */ +function readStreamdownKey(): string | null { + const source = ts.createSourceFile( + SURFACE_PATH, + readFileSync(SURFACE_PATH, "utf8"), + ts.ScriptTarget.ESNext, + true, + ts.ScriptKind.TSX, + ); + let key: string | null = null; + const visit = (node: ts.Node): void => { + const opening = openingTag(node); + if (opening?.tagName.getText() === "Streamdown") { + for (const attribute of opening.attributes.properties) { + if ( + ts.isJsxAttribute(attribute) && + attribute.name.getText() === "key" + ) { + key = attribute.initializer?.getText() ?? ""; + } + } + } + node.forEachChild(visit); + }; + source.forEachChild(visit); + return key; +} + +// Without this the suite passes with the key deleted, which is the regression. +// No DOM renderer is available here, so assert the wiring in the source. +test("the source view's Streamdown is keyed by the shipped helper", () => { + const key = readStreamdownKey(); + assert.ok(key, "source view has no key prop"); + assert.match(key, KEYED_BY_HELPER); +});