diff --git a/apps/web/src/artifacts/html-links.ts b/apps/web/src/artifacts/html-links.ts
new file mode 100644
index 000000000..72df433f1
--- /dev/null
+++ b/apps/web/src/artifacts/html-links.ts
@@ -0,0 +1,234 @@
+export function resolveHtmlArtifactFileName(input: {
+ baseName: string;
+ ext: '.html' | '.jsx' | '.tsx';
+ existingFileNames: ReadonlySet;
+ savedArtifactName?: string | null;
+ canOverwriteExistingEntry?: boolean;
+}): string {
+ const preferredFileName = `${input.baseName}${input.ext}`;
+ if (
+ input.ext === '.html' &&
+ input.baseName.toLowerCase() === 'index' &&
+ (!input.existingFileNames.has(preferredFileName) ||
+ input.savedArtifactName === preferredFileName ||
+ input.canOverwriteExistingEntry === true)
+ ) {
+ return 'index.html';
+ }
+
+ let fileName = preferredFileName;
+ let n = 2;
+ while (input.existingFileNames.has(fileName) && input.savedArtifactName !== fileName) {
+ fileName = `${input.baseName}-${n}${input.ext}`;
+ n += 1;
+ }
+ return fileName;
+}
+
+interface HtmlLinkProjectFile {
+ name: string;
+ path?: string;
+ kind?: string;
+ mime?: string;
+ mtime: number;
+ artifactManifest?: {
+ entry: string;
+ metadata?: Record;
+ };
+}
+
+interface HtmlLinkRewriteOptions {
+ artifactGroupIdentifier?: string;
+}
+
+export function canOverwriteHtmlArtifactEntry(input: {
+ baseName: string;
+ ext: '.html' | '.jsx' | '.tsx';
+ projectFiles: readonly HtmlLinkProjectFile[];
+ savedArtifactName?: string | null;
+ artifactIdentifier?: string;
+}): boolean {
+ if (input.ext !== '.html' || input.baseName.toLowerCase() !== 'index') return false;
+ if (input.savedArtifactName === 'index.html') return true;
+ const existingIndex = input.projectFiles.find((file) => {
+ return file.name === 'index.html' || file.path === 'index.html';
+ });
+ if (!existingIndex) return true;
+ const manifest = existingIndex.artifactManifest;
+ const artifactIdentifier = input.artifactIdentifier ?? '';
+ return (
+ artifactIdentifier.length > 0 &&
+ manifest?.entry === 'index.html' &&
+ manifest.metadata?.identifier === artifactIdentifier
+ );
+}
+
+export function rewriteHtmlLinksToCurrentProjectFiles(
+ html: string,
+ projectFiles: readonly HtmlLinkProjectFile[],
+ options: HtmlLinkRewriteOptions = {},
+): string {
+ const latestByTarget = buildLatestHtmlFileIndex(projectFiles, options);
+ if (latestByTarget.size === 0) return html;
+
+ return html.replace(
+ /\b(href)\s*=\s*(["'])([^"']+)\2/gi,
+ (match, attr: string, quote: string, rawValue: string) => {
+ const rewritten = rewriteHtmlLinkTarget(rawValue, latestByTarget);
+ return rewritten === rawValue ? match : `${attr}=${quote}${rewritten}${quote}`;
+ },
+ );
+}
+
+function buildLatestHtmlFileIndex(
+ projectFiles: readonly HtmlLinkProjectFile[],
+ options: HtmlLinkRewriteOptions,
+): Map {
+ const groupedLatest = new Map();
+ const legacyLatest = new Map();
+ const groupedFamilies = new Set();
+ const artifactGroupIdentifier = normalizeOptionalIdentifier(options.artifactGroupIdentifier);
+ for (const file of projectFiles) {
+ if (!isHtmlProjectFile(file)) continue;
+ const key = htmlFileFamilyKey(file.name);
+ if (!key) continue;
+ const manifestMatch = htmlFileManifestFamilyMatch(file, key);
+ if (!manifestMatch) continue;
+
+ if (manifestMatch.artifactGroupIdentifier !== null) {
+ groupedFamilies.add(key);
+ }
+
+ if (
+ artifactGroupIdentifier !== null &&
+ manifestMatch.artifactGroupIdentifier === artifactGroupIdentifier
+ ) {
+ const current = groupedLatest.get(key);
+ if (!current || file.mtime > current.mtime) groupedLatest.set(key, file);
+ continue;
+ }
+
+ if (manifestMatch.artifactGroupIdentifier === null) {
+ const current = legacyLatest.get(key);
+ if (!current || file.mtime > current.mtime) legacyLatest.set(key, file);
+ }
+ }
+
+ const latest = new Map();
+ for (const [key, file] of legacyLatest) {
+ if (!groupedFamilies.has(key)) latest.set(key, file);
+ }
+ for (const [key, file] of groupedLatest) {
+ latest.set(key, file);
+ }
+ return new Map(Array.from(latest, ([key, file]) => [key, file.name]));
+}
+
+function htmlFileManifestFamilyMatch(
+ file: HtmlLinkProjectFile,
+ familyKey: string,
+): { artifactGroupIdentifier: string | null } | null {
+ const identifier = file.artifactManifest?.metadata?.identifier;
+ if (typeof identifier !== 'string') return null;
+ const normalizedIdentifier = normalizeArtifactIdentifier(identifier);
+ if (!normalizedIdentifier) return null;
+ if (normalizedIdentifier !== htmlFileFamilyIdentifier(familyKey)) return null;
+
+ return {
+ artifactGroupIdentifier: normalizeOptionalIdentifier(
+ file.artifactManifest?.metadata?.artifactGroupIdentifier,
+ ),
+ };
+}
+
+function isHtmlProjectFile(file: HtmlLinkProjectFile): boolean {
+ const name = file.name.toLowerCase();
+ return (
+ (file.kind === undefined || file.kind === 'html') &&
+ (file.mime === undefined || file.mime === 'text/html') &&
+ (name.endsWith('.html') || name.endsWith('.htm'))
+ );
+}
+
+function rewriteHtmlLinkTarget(
+ value: string,
+ latestByTarget: ReadonlyMap,
+): string {
+ if (!value || value.startsWith('#') || isExternalOrOpaqueUrl(value)) return value;
+
+ const parsed = splitRelativeReference(value);
+ if (!isHtmlPath(parsed.pathname)) return value;
+ const key = htmlFileFamilyKey(parsed.pathname);
+ if (!key) return value;
+
+ const latest = latestByTarget.get(key);
+ if (!latest || latest === parsed.pathname) return value;
+
+ const rewrittenPath = preserveRelativePrefix(parsed.pathname, latest);
+ return `${rewrittenPath}${parsed.search}${parsed.hash}`;
+}
+
+function isExternalOrOpaqueUrl(value: string): boolean {
+ return /^[a-z][a-z0-9+.-]*:/i.test(value) || value.startsWith('//');
+}
+
+function splitRelativeReference(value: string): {
+ pathname: string;
+ search: string;
+ hash: string;
+} {
+ const hashIndex = value.indexOf('#');
+ const beforeHash = hashIndex >= 0 ? value.slice(0, hashIndex) : value;
+ const hash = hashIndex >= 0 ? value.slice(hashIndex) : '';
+ const queryIndex = beforeHash.indexOf('?');
+ return {
+ pathname: queryIndex >= 0 ? beforeHash.slice(0, queryIndex) : beforeHash,
+ search: queryIndex >= 0 ? beforeHash.slice(queryIndex) : '',
+ hash,
+ };
+}
+
+function isHtmlPath(pathname: string): boolean {
+ const lower = pathname.toLowerCase();
+ return lower.endsWith('.html') || lower.endsWith('.htm');
+}
+
+function htmlFileFamilyKey(pathname: string): string | null {
+ const slash = pathname.lastIndexOf('/');
+ const directory = slash >= 0 ? pathname.slice(0, slash + 1) : '';
+ const fileName = slash >= 0 ? pathname.slice(slash + 1) : pathname;
+ const dot = fileName.lastIndexOf('.');
+ if (dot <= 0) return null;
+ const stem = fileName.slice(0, dot);
+ const ext = fileName.slice(dot).toLowerCase();
+ const familyStem = stem.replace(/-\d+$/, '');
+ return `${directory}${familyStem}${ext}`.replace(/^\.\//, '');
+}
+
+function htmlFileFamilyIdentifier(familyKey: string): string {
+ const slash = familyKey.lastIndexOf('/');
+ const fileName = slash >= 0 ? familyKey.slice(slash + 1) : familyKey;
+ const dot = fileName.lastIndexOf('.');
+ const stem = dot > 0 ? fileName.slice(0, dot) : fileName;
+ return normalizeArtifactIdentifier(stem);
+}
+
+function normalizeArtifactIdentifier(value: string): string {
+ return value
+ .toLowerCase()
+ .replace(/[^a-z0-9_-]+/g, '-')
+ .replace(/^-+|-+$/g, '');
+}
+
+function normalizeOptionalIdentifier(value: unknown): string | null {
+ if (typeof value !== 'string') return null;
+ const normalized = normalizeArtifactIdentifier(value);
+ return normalized || null;
+}
+
+function preserveRelativePrefix(originalPathname: string, latestName: string): string {
+ if (originalPathname.startsWith('./') && !latestName.startsWith('./')) {
+ return `./${latestName}`;
+ }
+ return latestName;
+}
diff --git a/apps/web/src/components/ProjectView.tsx b/apps/web/src/components/ProjectView.tsx
index 9a52d7ff2..cb48c504d 100644
--- a/apps/web/src/components/ProjectView.tsx
+++ b/apps/web/src/components/ProjectView.tsx
@@ -11,6 +11,11 @@ import {
} from 'react';
import { createHtmlArtifactManifest, inferLegacyManifest } from '../artifacts/manifest';
import { resolveHtmlPointerArtifactTarget } from '../artifacts/pointer';
+import {
+ canOverwriteHtmlArtifactEntry,
+ resolveHtmlArtifactFileName,
+ rewriteHtmlLinksToCurrentProjectFiles,
+} from '../artifacts/html-links';
import { validateHtmlArtifact } from '../artifacts/validate';
import { createArtifactParser } from '../artifacts/parser';
import { useI18n } from '../i18n';
@@ -1135,20 +1140,40 @@ export function ProjectView({
async (art: Artifact, projectFilesSnapshot?: ProjectFile[]) => {
const baseName = artifactBaseNameFor(art);
const ext = artifactExtensionFor(art);
- // Pick a name that doesn't collide with an existing project file.
- // The first run uses `.`; subsequent runs append `-2`, `-3`…
- // so prior artifacts aren't silently overwritten.
+ // Pick a name that doesn't collide with an existing project file while
+ // keeping index.html stable for multi-page HTML artifacts.
const currentProjectFiles = projectFilesSnapshot ?? projectFilesRef.current;
const existing = new Set(currentProjectFiles.map((f) => f.name));
- let fileName = `${baseName}${ext}`;
- let n = 2;
- while (existing.has(fileName) && savedArtifactRef.current !== fileName) {
- fileName = `${baseName}-${n}${ext}`;
- n += 1;
- }
+ const canOverwriteExistingEntry = canOverwriteHtmlArtifactEntry({
+ baseName,
+ ext,
+ projectFiles: currentProjectFiles,
+ savedArtifactName: savedArtifactRef.current,
+ artifactIdentifier: art.identifier,
+ });
+ const fileName = resolveHtmlArtifactFileName({
+ baseName,
+ ext,
+ existingFileNames: existing,
+ savedArtifactName: savedArtifactRef.current,
+ canOverwriteExistingEntry,
+ });
+ const artifactGroupIdentifier =
+ ext === '.html'
+ ? htmlArtifactGroupIdentifierFor(art, currentProjectFiles, fileName, {
+ canReuseExistingGroup:
+ canOverwriteExistingEntry || savedArtifactRef.current === fileName,
+ })
+ : undefined;
+ const html =
+ ext === '.html'
+ ? rewriteHtmlLinksToCurrentProjectFiles(art.html, currentProjectFiles, {
+ artifactGroupIdentifier,
+ })
+ : art.html;
if (ext === '.html') {
const pointerTarget = resolveHtmlPointerArtifactTarget({
- content: art.html,
+ content: html,
candidateFileName: fileName,
projectFiles: currentProjectFiles,
});
@@ -1165,7 +1190,7 @@ export function ProjectView({
// when only Edit-tool changes happened this turn. Without this guard,
// such content lands as a phantom HTML file in the project panel.
if (ext === '.html') {
- const validation = validateHtmlArtifact(art.html);
+ const validation = validateHtmlArtifact(html);
if (!validation.ok) {
setError(`Refused to save artifact "${art.identifier || art.title || 'untitled'}": ${validation.reason}`);
return;
@@ -1178,6 +1203,7 @@ export function ProjectView({
identifier: art.identifier,
artifactType: art.artifactType,
inferred: false,
+ ...(artifactGroupIdentifier ? { artifactGroupIdentifier } : {}),
};
const manifest =
ext === '.html'
@@ -1197,7 +1223,7 @@ export function ProjectView({
designSystemId: project.designSystemId,
},
});
- const file = await writeProjectTextFile(project.id, fileName, art.html, {
+ const file = await writeProjectTextFile(project.id, fileName, html, {
artifactManifest: manifest ?? undefined,
});
if (file) {
@@ -4618,6 +4644,52 @@ function artifactBaseNameFor(art: Artifact): string {
);
}
+function htmlArtifactGroupIdentifierFor(
+ art: Artifact,
+ projectFiles: ProjectFile[],
+ fileName: string,
+ options: { canReuseExistingGroup: boolean },
+): string {
+ const existingFile = projectFiles.find((file) => file.name === fileName || file.path === fileName);
+ const existingFileGroup = existingArtifactGroupIdentifier(
+ existingFile?.artifactManifest?.metadata?.artifactGroupIdentifier,
+ );
+ if (options.canReuseExistingGroup && existingFileGroup) {
+ return existingFileGroup;
+ }
+
+ if (options.canReuseExistingGroup) {
+ const identifier = normalizeArtifactGroupSegment(art.identifier);
+ if (identifier) {
+ const existingGroup = projectFiles
+ .filter((file) => {
+ return normalizeArtifactGroupSegment(file.artifactManifest?.metadata?.identifier) === identifier;
+ })
+ .sort((a, b) => b.mtime - a.mtime)
+ .map((file) => file.artifactManifest?.metadata?.artifactGroupIdentifier)
+ .find((value): value is string => {
+ return typeof value === 'string' && normalizeArtifactGroupSegment(value).length > 0;
+ });
+ if (existingGroup) return existingGroup;
+ }
+ }
+
+ return `html-artifact:${fileName}`;
+}
+
+function existingArtifactGroupIdentifier(value: unknown): string | null {
+ if (typeof value !== 'string') return null;
+ return normalizeArtifactGroupSegment(value).length > 0 ? value : null;
+}
+
+function normalizeArtifactGroupSegment(value: unknown): string {
+ if (typeof value !== 'string') return '';
+ return value
+ .toLowerCase()
+ .replace(/[^a-z0-9_-]+/g, '-')
+ .replace(/^-+|-+$/g, '');
+}
+
export function findExistingArtifactProjectFile(
art: Artifact,
projectFiles: ProjectFile[],
diff --git a/apps/web/tests/artifacts/html-links.test.ts b/apps/web/tests/artifacts/html-links.test.ts
new file mode 100644
index 000000000..b0d06a593
--- /dev/null
+++ b/apps/web/tests/artifacts/html-links.test.ts
@@ -0,0 +1,206 @@
+import { describe, expect, it } from 'vitest';
+
+import {
+ canOverwriteHtmlArtifactEntry,
+ resolveHtmlArtifactFileName,
+ rewriteHtmlLinksToCurrentProjectFiles,
+} from '../../src/artifacts/html-links';
+
+describe('resolveHtmlArtifactFileName', () => {
+ it('keeps index.html as the stable entry point when the saved artifact already owns it', () => {
+ expect(
+ resolveHtmlArtifactFileName({
+ baseName: 'index',
+ ext: '.html',
+ existingFileNames: new Set(['index.html']),
+ savedArtifactName: 'index.html',
+ }),
+ ).toBe('index.html');
+ });
+
+ it('uses a suffix for index.html when the existing file is unrelated', () => {
+ expect(
+ resolveHtmlArtifactFileName({
+ baseName: 'index',
+ ext: '.html',
+ existingFileNames: new Set(['index.html']),
+ }),
+ ).toBe('index-2.html');
+ });
+
+ it('keeps index.html when overwrite ownership is proven by the caller', () => {
+ expect(
+ resolveHtmlArtifactFileName({
+ baseName: 'index',
+ ext: '.html',
+ existingFileNames: new Set(['index.html']),
+ canOverwriteExistingEntry: true,
+ }),
+ ).toBe('index.html');
+ });
+
+ it('keeps numbered collision names for non-entry html artifacts', () => {
+ expect(
+ resolveHtmlArtifactFileName({
+ baseName: 'about',
+ ext: '.html',
+ existingFileNames: new Set(['about.html', 'about-2.html']),
+ }),
+ ).toBe('about-3.html');
+ });
+});
+
+describe('canOverwriteHtmlArtifactEntry', () => {
+ it('allows index.html overwrite when the existing manifest identifier matches', () => {
+ expect(
+ canOverwriteHtmlArtifactEntry({
+ baseName: 'index',
+ ext: '.html',
+ projectFiles: [
+ htmlFile('index.html', 10, { artifactIdentifier: 'index' }),
+ ],
+ artifactIdentifier: 'index',
+ }),
+ ).toBe(true);
+ });
+
+ it('rejects index.html overwrite when the existing manifest identifier is empty', () => {
+ expect(
+ canOverwriteHtmlArtifactEntry({
+ baseName: 'index',
+ ext: '.html',
+ projectFiles: [
+ htmlFile('index.html', 10, { artifactIdentifier: '' }),
+ ],
+ artifactIdentifier: '',
+ }),
+ ).toBe(false);
+ });
+});
+
+describe('rewriteHtmlLinksToCurrentProjectFiles', () => {
+ it('rewrites relative html links to the newest matching project file', () => {
+ const html =
+ '' +
+ 'About' +
+ 'Contact' +
+ 'Local' +
+ 'External' +
+ '';
+
+ const out = rewriteHtmlLinksToCurrentProjectFiles(html, [
+ htmlFile('about.html', 10, { artifactIdentifier: 'about', artifactGroupIdentifier: 'site-a' }),
+ htmlFile('about-2.html', 30, { artifactIdentifier: 'about', artifactGroupIdentifier: 'site-a' }),
+ htmlFile('contact.html', 20, { artifactIdentifier: 'contact', artifactGroupIdentifier: 'site-a' }),
+ htmlFile('contact-2.html', 40, { artifactIdentifier: 'contact', artifactGroupIdentifier: 'site-a' }),
+ ], { artifactGroupIdentifier: 'site-a' });
+
+ expect(out).toContain('href="about-2.html"');
+ expect(out).toContain('href="contact-2.html?tab=team#lead"');
+ expect(out).toContain('href="#local"');
+ expect(out).toContain('href="https://example.com/about.html"');
+ });
+
+ it('rewrites home links when the artifact entry had to move off index.html', () => {
+ const html =
+ '' +
+ 'Home' +
+ 'Top' +
+ '';
+
+ const out = rewriteHtmlLinksToCurrentProjectFiles(html, [
+ htmlFile('index.html', 10, { artifactIdentifier: 'index', artifactGroupIdentifier: 'site-a' }),
+ htmlFile('index-2.html', 40, { artifactIdentifier: 'index', artifactGroupIdentifier: 'site-a' }),
+ ], { artifactGroupIdentifier: 'site-a' });
+
+ expect(out).toContain('href="index-2.html"');
+ expect(out).toContain('href="./index-2.html#top"');
+ });
+
+ it('does not rewrite to an unrelated newer numbered html file', () => {
+ const html =
+ '' +
+ 'About' +
+ '';
+
+ const out = rewriteHtmlLinksToCurrentProjectFiles(html, [
+ htmlFile('about.html', 10, { artifactIdentifier: 'about', artifactGroupIdentifier: 'site-a' }),
+ htmlFile('about-2.html', 40, { artifactIdentifier: 'other-about', artifactGroupIdentifier: 'site-b' }),
+ ], { artifactGroupIdentifier: 'site-a' });
+
+ expect(out).toContain('href="about.html"');
+ expect(out).not.toContain('href="about-2.html"');
+ });
+
+ it('does not rewrite to another artifact group with the same page identifier', () => {
+ const html =
+ '' +
+ 'About' +
+ '';
+
+ const out = rewriteHtmlLinksToCurrentProjectFiles(html, [
+ htmlFile('about.html', 10, { artifactIdentifier: 'about', artifactGroupIdentifier: 'site-a' }),
+ htmlFile('about-2.html', 40, { artifactIdentifier: 'about', artifactGroupIdentifier: 'site-b' }),
+ ], { artifactGroupIdentifier: 'site-a' });
+
+ expect(out).toContain('href="about.html"');
+ expect(out).not.toContain('href="about-2.html"');
+ });
+
+ it('falls back to legacy same-identifier files when no group-tagged candidates exist', () => {
+ const html =
+ '' +
+ 'About' +
+ '';
+
+ const out = rewriteHtmlLinksToCurrentProjectFiles(html, [
+ htmlFile('about.html', 10, { artifactIdentifier: 'about' }),
+ htmlFile('about-2.html', 40, { artifactIdentifier: 'about' }),
+ ], { artifactGroupIdentifier: 'site-a' });
+
+ expect(out).toContain('href="about-2.html"');
+ });
+
+ it('does not fall back to legacy files when a group-tagged candidate family exists', () => {
+ const html =
+ '' +
+ 'About' +
+ '';
+
+ const out = rewriteHtmlLinksToCurrentProjectFiles(html, [
+ htmlFile('about.html', 10, { artifactIdentifier: 'about' }),
+ htmlFile('about-2.html', 20, { artifactIdentifier: 'about' }),
+ htmlFile('about-3.html', 40, {
+ artifactIdentifier: 'about',
+ artifactGroupIdentifier: 'site-b',
+ }),
+ ], { artifactGroupIdentifier: 'site-a' });
+
+ expect(out).toContain('href="about.html"');
+ expect(out).not.toContain('href="about-2.html"');
+ expect(out).not.toContain('href="about-3.html"');
+ });
+});
+
+function htmlFile(
+ name: string,
+ mtime: number,
+ options: { artifactIdentifier?: string; artifactGroupIdentifier?: string } = {},
+) {
+ return {
+ name,
+ kind: 'html',
+ mime: 'text/html',
+ size: 1,
+ mtime,
+ artifactManifest: options.artifactIdentifier !== undefined
+ ? {
+ entry: name,
+ metadata: {
+ identifier: options.artifactIdentifier,
+ artifactGroupIdentifier: options.artifactGroupIdentifier,
+ },
+ }
+ : undefined,
+ };
+}
diff --git a/apps/web/tests/components/ProjectView.api-empty-response.test.tsx b/apps/web/tests/components/ProjectView.api-empty-response.test.tsx
index e1c805f0e..10bca076e 100644
--- a/apps/web/tests/components/ProjectView.api-empty-response.test.tsx
+++ b/apps/web/tests/components/ProjectView.api-empty-response.test.tsx
@@ -558,6 +558,177 @@ describe('ProjectView API empty response handling', () => {
expect(screen.queryByText(/Refused to save artifact/i)).toBeNull();
});
+ it('keeps regenerated multipage artifact entry at index.html and rewrites child links', async () => {
+ mockedFetchProjectFiles.mockResolvedValue([
+ htmlProjectFile('index.html', 10, {
+ artifactIdentifier: 'index',
+ artifactGroupIdentifier: 'site-a',
+ }),
+ htmlProjectFile('about.html', 20, {
+ artifactIdentifier: 'about',
+ artifactGroupIdentifier: 'site-a',
+ }),
+ htmlProjectFile('about-2.html', 40, {
+ artifactIdentifier: 'about',
+ artifactGroupIdentifier: 'site-a',
+ }),
+ ] as never);
+ mockedWriteProjectTextFile.mockResolvedValue(htmlProjectFile('index.html', 50) as never);
+ const artifact =
+ '' +
+ 'Home' +
+ 'Home
About' +
+ '' +
+ '';
+ mockedStreamMessage.mockImplementation(async (
+ _cfg: AppConfig,
+ _system: string,
+ _history: ChatMessage[],
+ _signal: AbortSignal,
+ handlers: StreamHandlers,
+ ) => {
+ handlers.onDelta(artifact);
+ handlers.onDone('');
+ });
+ renderProjectView();
+
+ await sendTestPrompt();
+
+ await waitFor(() => {
+ expect(mockedWriteProjectTextFile).toHaveBeenCalled();
+ });
+ const [, fileName, content] = mockedWriteProjectTextFile.mock.calls.at(-1) ?? [];
+ expect(fileName).toBe('index.html');
+ expect(content).toContain('href="about-2.html"');
+ });
+
+ it('does not overwrite an unrelated existing index.html when saving a multipage artifact', async () => {
+ mockedFetchProjectFiles.mockResolvedValue([
+ htmlProjectFile('index.html', 10),
+ htmlProjectFile('about.html', 20, {
+ artifactIdentifier: 'about',
+ artifactGroupIdentifier: 'html-artifact:index-2.html',
+ }),
+ htmlProjectFile('about-2.html', 40, {
+ artifactIdentifier: 'about',
+ artifactGroupIdentifier: 'html-artifact:index-2.html',
+ }),
+ ] as never);
+ mockedWriteProjectTextFile.mockResolvedValue(htmlProjectFile('index-2.html', 50) as never);
+ const artifact =
+ '' +
+ 'Home' +
+ 'Home
About' +
+ '' +
+ '';
+ mockedStreamMessage.mockImplementation(async (
+ _cfg: AppConfig,
+ _system: string,
+ _history: ChatMessage[],
+ _signal: AbortSignal,
+ handlers: StreamHandlers,
+ ) => {
+ handlers.onDelta(artifact);
+ handlers.onDone('');
+ });
+ renderProjectView();
+
+ await sendTestPrompt();
+
+ await waitFor(() => {
+ expect(mockedWriteProjectTextFile).toHaveBeenCalled();
+ });
+ const [, fileName, content] = mockedWriteProjectTextFile.mock.calls.at(-1) ?? [];
+ expect(fileName).toBe('index-2.html');
+ expect(content).toContain('href="about-2.html"');
+ });
+
+ it('starts a fresh group when a new index artifact is relocated away from an existing owner', async () => {
+ mockedFetchProjectFiles.mockResolvedValue([
+ htmlProjectFile('index.html', 10, {
+ artifactIdentifier: 'Index',
+ artifactGroupIdentifier: 'site-a',
+ }),
+ htmlProjectFile('about.html', 20, {
+ artifactIdentifier: 'about',
+ artifactGroupIdentifier: 'site-a',
+ }),
+ htmlProjectFile('about-2.html', 40, {
+ artifactIdentifier: 'about',
+ artifactGroupIdentifier: 'site-a',
+ }),
+ ] as never);
+ mockedWriteProjectTextFile.mockResolvedValue(htmlProjectFile('index-2.html', 50) as never);
+ const artifact =
+ '' +
+ 'Home' +
+ 'Home
About' +
+ '' +
+ '';
+ mockedStreamMessage.mockImplementation(async (
+ _cfg: AppConfig,
+ _system: string,
+ _history: ChatMessage[],
+ _signal: AbortSignal,
+ handlers: StreamHandlers,
+ ) => {
+ handlers.onDelta(artifact);
+ handlers.onDone('');
+ });
+ renderProjectView();
+
+ await sendTestPrompt();
+
+ await waitFor(() => {
+ expect(mockedWriteProjectTextFile).toHaveBeenCalled();
+ });
+ const [, fileName, content, options] = mockedWriteProjectTextFile.mock.calls.at(-1) ?? [];
+ expect(fileName).toBe('index-2.html');
+ expect(content).toContain('href="about.html"');
+ expect(content).not.toContain('href="about-2.html"');
+ expect(options?.artifactManifest?.metadata?.artifactGroupIdentifier).toBe(
+ 'html-artifact:index-2.html',
+ );
+ });
+
+ it('rewrites child links for legacy multipage artifacts without group metadata', async () => {
+ mockedFetchProjectFiles.mockResolvedValue([
+ htmlProjectFile('index.html', 10, { artifactIdentifier: 'index' }),
+ htmlProjectFile('about.html', 20, { artifactIdentifier: 'about' }),
+ htmlProjectFile('about-2.html', 40, { artifactIdentifier: 'about' }),
+ ] as never);
+ mockedWriteProjectTextFile.mockResolvedValue(htmlProjectFile('index.html', 50) as never);
+ const artifact =
+ '' +
+ 'Home' +
+ 'Home
About' +
+ '' +
+ '';
+ mockedStreamMessage.mockImplementation(async (
+ _cfg: AppConfig,
+ _system: string,
+ _history: ChatMessage[],
+ _signal: AbortSignal,
+ handlers: StreamHandlers,
+ ) => {
+ handlers.onDelta(artifact);
+ handlers.onDone('');
+ });
+ renderProjectView();
+
+ await sendTestPrompt();
+
+ await waitFor(() => {
+ expect(mockedWriteProjectTextFile).toHaveBeenCalled();
+ });
+ const [, fileName, content, options] = mockedWriteProjectTextFile.mock.calls.at(-1) ?? [];
+ expect(fileName).toBe('index.html');
+ expect(content).toContain('href="about-2.html"');
+ expect(options?.artifactManifest?.metadata?.artifactGroupIdentifier).toBe(
+ 'html-artifact:index.html',
+ );
+ });
+
it('injects ElevenLabs voice options into API-mode audio project prompts', async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
@@ -693,3 +864,34 @@ function hasSavedAssistantMessage(predicate: (message: ChatMessage) => boolean):
return message.role === 'assistant' && predicate(message);
});
}
+
+function htmlProjectFile(
+ name: string,
+ mtime: number,
+ options: { artifactIdentifier?: string; artifactGroupIdentifier?: string } = {},
+) {
+ return {
+ name,
+ path: name,
+ kind: 'html',
+ mime: 'text/html',
+ size: 1,
+ mtime,
+ artifactManifest: options.artifactIdentifier
+ ? {
+ version: 1,
+ kind: 'html',
+ title: name,
+ entry: name,
+ renderer: 'html',
+ status: 'complete',
+ exports: ['html'],
+ primary: true,
+ metadata: {
+ identifier: options.artifactIdentifier,
+ artifactGroupIdentifier: options.artifactGroupIdentifier,
+ },
+ }
+ : undefined,
+ };
+}