diff --git a/README.md b/README.md
index 31a377a8ad..711a23d198 100644
--- a/README.md
+++ b/README.md
@@ -1902,6 +1902,8 @@ regular files require a matching ETag; date-form `If-Range` requests receive the
CSV and TSV artifacts open as tables in the artifact panel and in a separate window. The preview preserves text values (including leading zeros), supports an optional header row, and pages through up to 200 rows and 50 columns from the initial sample. Long or multiline cells can be opened and copied in full. Switch to source to inspect or edit the file; downloads and separate windows use the saved version.
+HTML artifact previews resolve relative assets from the artifact directory unless the document contains a real `` element. Base-tag examples in comments or script text do not change that resolution.
+
If the sample cuts a CRLF line ending in half, the preview keeps the earlier complete rows and omits the incomplete final record, including when its last field is quoted.
Text artifacts are streamed with HTTP byte-range support. The Web UI initially
diff --git a/frontend/src/AGENTS.md b/frontend/src/AGENTS.md
index 1ea07862f0..1b4d8c562b 100644
--- a/frontend/src/AGENTS.md
+++ b/frontend/src/AGENTS.md
@@ -15,6 +15,11 @@ Only Markdown destinations decode once; relative images match decoded names
against raw artifact paths before encoding.
File-type detection uses the basename so extensionless `Dockerfile` and
`Makefile` artifacts remain recognizable under nested or dotted directories.
+HTML preview base detection skips DOM construction without case-insensitive `base`
+text; otherwise an inert template distinguishes real tags from comments, scripts
+and nested templates without rewriting source. Tests: `preview-base.dom.test.ts`
+(happy-dom); textarea/title RCDATA needs real-browser checks due to parser gaps.
+Other preview helpers retain the node test environment.
Language-map membership checks only own properties; inherited names such as
`constructor` and `__proto__` must keep the unknown-file download fallback.
diff --git a/frontend/src/core/artifacts/preview.ts b/frontend/src/core/artifacts/preview.ts
index 3fa40c7760..bca22e108c 100644
--- a/frontend/src/core/artifacts/preview.ts
+++ b/frontend/src/core/artifacts/preview.ts
@@ -175,10 +175,20 @@ export function appendHtmlPreviewBaseHref(
url?: string,
currentHref = globalThis.location?.href ?? "http://localhost/",
) {
- if (!url || /`;
// "(?:\s[^>]*)?" keeps the tag-name boundary so `` (a common
diff --git a/frontend/tests/unit/core/artifacts/preview-base.dom.test.ts b/frontend/tests/unit/core/artifacts/preview-base.dom.test.ts
new file mode 100644
index 0000000000..71fba1bae6
--- /dev/null
+++ b/frontend/tests/unit/core/artifacts/preview-base.dom.test.ts
@@ -0,0 +1,121 @@
+import { expect, rs, test } from "@rstest/core";
+
+import {
+ appendHtmlPreviewBaseHref,
+ appendHtmlPreviewScrollRestoration,
+} from "@/core/artifacts/preview";
+
+const ARTIFACT_URL = "/api/threads/thread-1/artifacts/report/index.html";
+const CURRENT_HREF = "https://deerflow.example/workspace/chats/thread-1";
+const BASE_ELEMENT =
+ '';
+
+// Textarea/title RCDATA is checked in a real browser: happy-dom misparses it here.
+test("skips DOM construction when HTML contains no base text", () => {
+ const html = `${"Report
".repeat(1024)}
`;
+ const createElement = rs.spyOn(document, "createElement");
+
+ try {
+ expect(appendHtmlPreviewBaseHref(html, ARTIFACT_URL, CURRENT_HREF)).toBe(
+ html.replace("", `${BASE_ELEMENT}`),
+ );
+ expect(createElement).not.toHaveBeenCalled();
+ } finally {
+ createElement.mockRestore();
+ }
+});
+
+test.each([
+ ["comment", ''],
+ [
+ "JSON script",
+ '',
+ ],
+ ["template", ''],
+])("ignores base-tag text in a %s", (_name, literal) => {
+ const html = `${literal}
`;
+
+ expect(appendHtmlPreviewBaseHref(html, ARTIFACT_URL, CURRENT_HREF)).toBe(
+ html.replace("", `${BASE_ELEMENT}`),
+ );
+});
+
+test("ignores a commented base in a fragment", () => {
+ const html = '
';
+
+ expect(appendHtmlPreviewBaseHref(html, ARTIFACT_URL, CURRENT_HREF)).toBe(
+ `${BASE_ELEMENT}${html}`,
+ );
+});
+
+test("preserves a real base element and the original HTML", () => {
+ const html =
+ '
';
+
+ expect(appendHtmlPreviewBaseHref(html, ARTIFACT_URL, CURRENT_HREF)).toBe(
+ html,
+ );
+});
+
+test("leaves HTML unchanged without an artifact URL", () => {
+ const html = '
';
+
+ expect(appendHtmlPreviewBaseHref(html)).toBe(html);
+});
+
+test("preserves existing head elements when injecting scroll restoration", () => {
+ const html =
+ 'content';
+ const result = appendHtmlPreviewScrollRestoration(
+ appendHtmlPreviewBaseHref(
+ html,
+ "/demo/threads/thread-1/user-data/outputs/report.html?download=true",
+ "http://localhost/workspace/chats/thread-1",
+ ),
+ "/artifact-fixtures/report.html",
+ );
+
+ expect(result).toContain(
+ '',
+ );
+ expect(
+ result.indexOf("data-deerflow-artifact-scroll-restoration"),
+ ).toBeLessThan(
+ result.indexOf(
+ '',
+ ),
+ );
+});
+
+test("does not mistake for when injecting the base href", () => {
+ // Agent-generated fragments often have no but open with ;
+ // the base must then be prepended so assets before the tag resolve too.
+ const html =
+ '
content';
+
+ const result = appendHtmlPreviewBaseHref(
+ html,
+ "/demo/threads/thread-1/user-data/outputs/report.html",
+ "http://localhost/workspace/chats/thread-1",
+ );
+
+ expect(result).toBe(
+ '' +
+ html,
+ );
+});
+
+test("injects the base href inside an attributed tag", () => {
+ const html =
+ 'x';
+
+ const result = appendHtmlPreviewBaseHref(
+ html,
+ "/demo/threads/thread-1/user-data/outputs/report.html",
+ "http://localhost/workspace/chats/thread-1",
+ );
+
+ expect(result).toContain(
+ '',
+ );
+});
diff --git a/frontend/tests/unit/core/artifacts/preview.test.ts b/frontend/tests/unit/core/artifacts/preview.test.ts
index 4d68d890ce..1c15a16f22 100644
--- a/frontend/tests/unit/core/artifacts/preview.test.ts
+++ b/frontend/tests/unit/core/artifacts/preview.test.ts
@@ -1,7 +1,6 @@
import { expect, test } from "@rstest/core";
import {
- appendHtmlPreviewBaseHref,
appendHtmlPreviewScrollRestoration,
buildWriteFileDraftContent,
createHtmlPreviewScrollKey,
@@ -258,63 +257,6 @@ test("injects scroll restoration at the start of the HTML head", () => {
);
});
-test("preserves existing head elements when injecting scroll restoration", () => {
- const html =
- 'content';
- const result = appendHtmlPreviewScrollRestoration(
- appendHtmlPreviewBaseHref(
- html,
- "/demo/threads/thread-1/user-data/outputs/report.html?download=true",
- "http://localhost/workspace/chats/thread-1",
- ),
- ARTIFACT_PATH,
- );
-
- expect(result).toContain(
- '',
- );
- expect(
- result.indexOf("data-deerflow-artifact-scroll-restoration"),
- ).toBeLessThan(
- result.indexOf(
- '',
- ),
- );
-});
-
-test("does not mistake for when injecting the base href", () => {
- // Agent-generated fragments often have no but open with ;
- // the base must then be prepended so assets before the tag resolve too.
- const html =
- '
content';
-
- const result = appendHtmlPreviewBaseHref(
- html,
- "/demo/threads/thread-1/user-data/outputs/report.html",
- "http://localhost/workspace/chats/thread-1",
- );
-
- expect(result).toBe(
- '' +
- html,
- );
-});
-
-test("injects the base href inside an attributed tag", () => {
- const html =
- 'x';
-
- const result = appendHtmlPreviewBaseHref(
- html,
- "/demo/threads/thread-1/user-data/outputs/report.html",
- "http://localhost/workspace/chats/thread-1",
- );
-
- expect(result).toContain(
- '',
- );
-});
-
test("does not duplicate HTML scroll restoration script", () => {
const html = appendHtmlPreviewScrollRestoration(
"x",