fix(studio): show the current artifact's source after switching artifacts (#7565)

* fix(studio): show the current artifact's source after switching artifacts

The canvas source view feeds one Streamdown a fence built from the selected
artifact's code, but never keys it. Streamdown does not revise a block it has
already committed, so the panel keeps rendering the previous artifact's source.
Key the source view on the artifact ID plus a hash of its code: tool artifact
IDs are derived from the tool call, not the code, so the ID alone does not
change when a tool artifact is updated in place.

* Name the real root cause and make the source-key test load-bearing

The remount is needed because Streamdown memoizes a fenced code block on its
hast node's line/column span, which ignores the text inside the fence, so two
canvases of equal line count compare equal and the old source stays on screen.
Verified in Chromium against streamdown 2.5.0: unkeyed, 70 lines -> 70 lines
renders the previous artifact, 70 -> 71 and 70 -> 90 render correctly.

Move the key expression into the source branch so it costs nothing while the
artifact is streaming and the view is unmounted, and export the helper from
types.ts so the test exercises the shipped code instead of a local copy of the
formula (it passed before even with the key removed from the component).

* Assert the source view's Streamdown key wiring, not just the helper

The suite exercised buildArtifactSourceKey but never the component, so deleting
key={buildArtifactSourceKey(artifact)} from the Streamdown left every test
green. There is no DOM renderer available to these tests, so parse
artifact-surface.tsx with the TypeScript compiler API (already a devDependency)
and assert the source view's Streamdown carries that key.

Mutation-checked: removing the key fails 1 test, swapping it for artifact.id
fails 1, and making the helper ignore code fails 2.

* Tighten the comments added by this PR
This commit is contained in:
Daniel Han 2026-07-28 18:19:39 -07:00 committed by GitHub
commit a0a3a7b24a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 142 additions and 1 deletions

View file

@ -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({
) : (
<div className="h-full overflow-auto text-xs leading-relaxed [&_[data-streamdown=code-block]]:!my-0 [&_[data-streamdown=code-block]]:!gap-0 [&_[data-streamdown=code-block]]:!rounded-none [&_[data-streamdown=code-block]]:!border-0 [&_[data-streamdown=code-block]]:!bg-transparent [&_[data-streamdown=code-block]]:!p-0 [&_[data-streamdown=code-block-body]]:!border-0 [&_[data-streamdown=code-block-body]]:!bg-transparent [&_[data-streamdown=code-block-body]]:!p-0 [&_pre]:!m-0 [&_pre]:!bg-transparent [&_pre]:!p-0 [&_pre]:text-xs [&_pre]:leading-relaxed [&_code]:text-xs">
<Streamdown
// Only computed when the source view is actually on screen.
key={buildArtifactSourceKey(artifact)}
mode="streaming"
plugins={{ code: artifactSourceCodePlugin }}
controls={{ code: false }}

View file

@ -41,6 +41,15 @@ export function hashArtifactCode(code: string): string {
return (hash >>> 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<ChatArtifact, "id" | "code">,
): string {
return `${artifact.id}:${hashArtifactCode(artifact.code)}`;
}
export function createArtifactId(input: ChatArtifactInput): string {
const threadSegment = input.threadId || "no-thread";
const messageSegment = input.sourceMessageId || "transient";

View file

@ -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("<p>first</p>"));
const second = createArtifactId(toolInput("<p>second</p>"));
assert.equal(first, second);
});
test("the source key changes when a tool artifact's code changes", () => {
const first = createChatArtifact(toolInput("<p>first</p>"));
const second = createChatArtifact(toolInput("<p>second</p>"));
assert.notEqual(sourceKey(first), sourceKey(second));
});
test("the source key changes when switching between fence artifacts", () => {
const first = createChatArtifact(fenceInput("<p>alpha</p>"));
const second = createChatArtifact(fenceInput("<p>bravo</p>"));
assert.notEqual(sourceKey(first), sourceKey(second));
});
test("the source key is stable for an unchanged artifact, so no needless remount", () => {
const code = "<p>same</p>";
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("<html>\n<body>\n<h1>Alpha</h1>\n</body>\n</html>"),
);
const second = createChatArtifact(
toolInput("<html>\n<body>\n<h1>Bravo</h1>\n</body>\n</html>"),
);
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("<p>ab</p>"), hashArtifactCode("<p>ba</p>"));
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 `<x>` and `<x />`. */
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 <Streamdown> has no key prop");
assert.match(key, KEYED_BY_HELPER);
});