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\nAlpha
\n\n"),
+ );
+ const second = createChatArtifact(
+ toolInput("\n\nBravo
\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);
+});