From 66cbb5b2c2005d3edfa56ab3bd31aafb1304d3e7 Mon Sep 17 00:00:00 2001 From: 116405 <116405@ky-tech.com.cn> Date: Sat, 30 May 2026 17:00:03 +0800 Subject: [PATCH 1/8] fix(web): keep multipage artifact links current --- apps/web/src/artifacts/html-links.ts | 126 ++++++++++++++++++ apps/web/src/components/ProjectView.tsx | 31 +++-- apps/web/tests/artifacts/html-links.test.ts | 62 +++++++++ .../ProjectView.api-empty-response.test.tsx | 46 +++++++ 4 files changed, 253 insertions(+), 12 deletions(-) create mode 100644 apps/web/src/artifacts/html-links.ts create mode 100644 apps/web/tests/artifacts/html-links.test.ts diff --git a/apps/web/src/artifacts/html-links.ts b/apps/web/src/artifacts/html-links.ts new file mode 100644 index 000000000..0e1a3c91d --- /dev/null +++ b/apps/web/src/artifacts/html-links.ts @@ -0,0 +1,126 @@ +export function resolveHtmlArtifactFileName(input: { + baseName: string; + ext: '.html' | '.jsx' | '.tsx'; + existingFileNames: ReadonlySet; + savedArtifactName?: string | null; +}): string { + if (input.ext === '.html' && input.baseName.toLowerCase() === 'index') { + return 'index.html'; + } + + let fileName = `${input.baseName}${input.ext}`; + 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; +} + +export function rewriteHtmlLinksToCurrentProjectFiles( + html: string, + projectFiles: readonly HtmlLinkProjectFile[], +): string { + const latestByTarget = buildLatestHtmlFileIndex(projectFiles); + 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[]): Map { + const latest = new Map(); + for (const file of projectFiles) { + if (!isHtmlProjectFile(file)) continue; + const key = htmlFileFamilyKey(file.name); + if (!key) continue; + const current = latest.get(key); + if (!current || file.mtime > current.mtime) latest.set(key, file); + } + return new Map(Array.from(latest, ([key, file]) => [key, file.name])); +} + +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(); + if (stem.toLowerCase() === 'index') return null; + const familyStem = stem.replace(/-\d+$/, ''); + return `${directory}${familyStem}${ext}`.replace(/^\.\//, ''); +} + +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 0946629d1..40f099199 100644 --- a/apps/web/src/components/ProjectView.tsx +++ b/apps/web/src/components/ProjectView.tsx @@ -11,6 +11,10 @@ import { } from 'react'; import { createHtmlArtifactManifest, inferLegacyManifest } from '../artifacts/manifest'; import { resolveHtmlPointerArtifactTarget } from '../artifacts/pointer'; +import { + resolveHtmlArtifactFileName, + rewriteHtmlLinksToCurrentProjectFiles, +} from '../artifacts/html-links'; import { validateHtmlArtifact } from '../artifacts/validate'; import { createArtifactParser } from '../artifacts/parser'; import { useI18n } from '../i18n'; @@ -1135,20 +1139,23 @@ 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 fileName = resolveHtmlArtifactFileName({ + baseName, + ext, + existingFileNames: existing, + savedArtifactName: savedArtifactRef.current, + }); + const html = + ext === '.html' + ? rewriteHtmlLinksToCurrentProjectFiles(art.html, currentProjectFiles) + : art.html; if (ext === '.html') { const pointerTarget = resolveHtmlPointerArtifactTarget({ - content: art.html, + content: html, candidateFileName: fileName, projectFiles: currentProjectFiles, }); @@ -1165,7 +1172,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; @@ -1197,7 +1204,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) { 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..cf7f51736 --- /dev/null +++ b/apps/web/tests/artifacts/html-links.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest'; + +import { + resolveHtmlArtifactFileName, + rewriteHtmlLinksToCurrentProjectFiles, +} from '../../src/artifacts/html-links'; + +describe('resolveHtmlArtifactFileName', () => { + it('keeps index.html as the stable entry point when it already exists', () => { + expect( + resolveHtmlArtifactFileName({ + baseName: 'index', + ext: '.html', + existingFileNames: new Set(['index.html']), + }), + ).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('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), + htmlFile('about-2.html', 30), + htmlFile('contact.html', 20), + htmlFile('contact-2.html', 40), + ]); + + 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"'); + }); +}); + +function htmlFile(name: string, mtime: number) { + return { + name, + kind: 'html', + mime: 'text/html', + size: 1, + mtime, + }; +} 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..9ac535f99 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,41 @@ 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), + htmlProjectFile('about.html', 20), + htmlProjectFile('about-2.html', 40), + ] 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('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 +728,14 @@ function hasSavedAssistantMessage(predicate: (message: ChatMessage) => boolean): return message.role === 'assistant' && predicate(message); }); } + +function htmlProjectFile(name: string, mtime: number) { + return { + name, + path: name, + kind: 'html', + mime: 'text/html', + size: 1, + mtime, + }; +} From a397c5e2c54aa3fb7c764ac64dfb9e4f3808205f Mon Sep 17 00:00:00 2001 From: 116405 <116405@ky-tech.com.cn> Date: Sat, 30 May 2026 17:19:50 +0800 Subject: [PATCH 2/8] fix(web): avoid unrelated index artifact overwrite --- apps/web/src/artifacts/html-links.ts | 37 +++++++++++- apps/web/src/components/ProjectView.tsx | 8 +++ apps/web/tests/artifacts/html-links.test.ts | 15 ++++- .../ProjectView.api-empty-response.test.tsx | 56 ++++++++++++++++++- 4 files changed, 110 insertions(+), 6 deletions(-) diff --git a/apps/web/src/artifacts/html-links.ts b/apps/web/src/artifacts/html-links.ts index 0e1a3c91d..991027523 100644 --- a/apps/web/src/artifacts/html-links.ts +++ b/apps/web/src/artifacts/html-links.ts @@ -3,12 +3,20 @@ export function resolveHtmlArtifactFileName(input: { ext: '.html' | '.jsx' | '.tsx'; existingFileNames: ReadonlySet; savedArtifactName?: string | null; + canOverwriteExistingEntry?: boolean; }): string { - if (input.ext === '.html' && input.baseName.toLowerCase() === 'index') { + 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 = `${input.baseName}${input.ext}`; + let fileName = preferredFileName; let n = 2; while (input.existingFileNames.has(fileName) && input.savedArtifactName !== fileName) { fileName = `${input.baseName}-${n}${input.ext}`; @@ -23,6 +31,31 @@ interface HtmlLinkProjectFile { kind?: string; mime?: string; mtime: number; + artifactManifest?: { + entry: string; + metadata?: Record; + }; +} + +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; + return ( + Boolean(input.artifactIdentifier) && + manifest?.entry === 'index.html' && + manifest.metadata?.identifier === input.artifactIdentifier + ); } export function rewriteHtmlLinksToCurrentProjectFiles( diff --git a/apps/web/src/components/ProjectView.tsx b/apps/web/src/components/ProjectView.tsx index 40f099199..c6e921129 100644 --- a/apps/web/src/components/ProjectView.tsx +++ b/apps/web/src/components/ProjectView.tsx @@ -12,6 +12,7 @@ import { import { createHtmlArtifactManifest, inferLegacyManifest } from '../artifacts/manifest'; import { resolveHtmlPointerArtifactTarget } from '../artifacts/pointer'; import { + canOverwriteHtmlArtifactEntry, resolveHtmlArtifactFileName, rewriteHtmlLinksToCurrentProjectFiles, } from '../artifacts/html-links'; @@ -1148,6 +1149,13 @@ export function ProjectView({ ext, existingFileNames: existing, savedArtifactName: savedArtifactRef.current, + canOverwriteExistingEntry: canOverwriteHtmlArtifactEntry({ + baseName, + ext, + projectFiles: currentProjectFiles, + savedArtifactName: savedArtifactRef.current, + artifactIdentifier: art.identifier, + }), }); const html = ext === '.html' diff --git a/apps/web/tests/artifacts/html-links.test.ts b/apps/web/tests/artifacts/html-links.test.ts index cf7f51736..6875c6468 100644 --- a/apps/web/tests/artifacts/html-links.test.ts +++ b/apps/web/tests/artifacts/html-links.test.ts @@ -6,14 +6,25 @@ import { } from '../../src/artifacts/html-links'; describe('resolveHtmlArtifactFileName', () => { - it('keeps index.html as the stable entry point when it already exists', () => { + 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.html'); + ).toBe('index-2.html'); }); it('keeps numbered collision names for non-entry html artifacts', () => { 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 9ac535f99..85f276a71 100644 --- a/apps/web/tests/components/ProjectView.api-empty-response.test.tsx +++ b/apps/web/tests/components/ProjectView.api-empty-response.test.tsx @@ -560,7 +560,7 @@ describe('ProjectView API empty response handling', () => { it('keeps regenerated multipage artifact entry at index.html and rewrites child links', async () => { mockedFetchProjectFiles.mockResolvedValue([ - htmlProjectFile('index.html', 10), + htmlProjectFile('index.html', 10, { artifactIdentifier: 'index' }), htmlProjectFile('about.html', 20), htmlProjectFile('about-2.html', 40), ] as never); @@ -593,6 +593,41 @@ describe('ProjectView API empty response handling', () => { 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), + htmlProjectFile('about-2.html', 40), + ] 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('injects ElevenLabs voice options into API-mode audio project prompts', async () => { const fetchMock = vi.fn(async (input: RequestInfo | URL) => { const url = String(input); @@ -729,7 +764,11 @@ function hasSavedAssistantMessage(predicate: (message: ChatMessage) => boolean): }); } -function htmlProjectFile(name: string, mtime: number) { +function htmlProjectFile( + name: string, + mtime: number, + options: { artifactIdentifier?: string } = {}, +) { return { name, path: name, @@ -737,5 +776,18 @@ function htmlProjectFile(name: string, mtime: number) { 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 }, + } + : undefined, }; } From 57035eabfc42b74ded9549e04ad60b0542f83a67 Mon Sep 17 00:00:00 2001 From: 116405 <116405@ky-tech.com.cn> Date: Sat, 30 May 2026 17:26:16 +0800 Subject: [PATCH 3/8] test(web): clarify html artifact ownership checks --- apps/web/src/artifacts/html-links.ts | 5 +- apps/web/tests/artifacts/html-links.test.ts | 52 ++++++++++++++++++++- 2 files changed, 54 insertions(+), 3 deletions(-) diff --git a/apps/web/src/artifacts/html-links.ts b/apps/web/src/artifacts/html-links.ts index 991027523..638142e5a 100644 --- a/apps/web/src/artifacts/html-links.ts +++ b/apps/web/src/artifacts/html-links.ts @@ -51,10 +51,11 @@ export function canOverwriteHtmlArtifactEntry(input: { }); if (!existingIndex) return true; const manifest = existingIndex.artifactManifest; + const artifactIdentifier = input.artifactIdentifier ?? ''; return ( - Boolean(input.artifactIdentifier) && + artifactIdentifier.length > 0 && manifest?.entry === 'index.html' && - manifest.metadata?.identifier === input.artifactIdentifier + manifest.metadata?.identifier === artifactIdentifier ); } diff --git a/apps/web/tests/artifacts/html-links.test.ts b/apps/web/tests/artifacts/html-links.test.ts index 6875c6468..dcc1e7360 100644 --- a/apps/web/tests/artifacts/html-links.test.ts +++ b/apps/web/tests/artifacts/html-links.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { + canOverwriteHtmlArtifactEntry, resolveHtmlArtifactFileName, rewriteHtmlLinksToCurrentProjectFiles, } from '../../src/artifacts/html-links'; @@ -27,6 +28,17 @@ describe('resolveHtmlArtifactFileName', () => { ).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({ @@ -38,6 +50,34 @@ describe('resolveHtmlArtifactFileName', () => { }); }); +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 = @@ -62,12 +102,22 @@ describe('rewriteHtmlLinksToCurrentProjectFiles', () => { }); }); -function htmlFile(name: string, mtime: number) { +function htmlFile( + name: string, + mtime: number, + options: { artifactIdentifier?: string } = {}, +) { return { name, kind: 'html', mime: 'text/html', size: 1, mtime, + artifactManifest: options.artifactIdentifier !== undefined + ? { + entry: name, + metadata: { identifier: options.artifactIdentifier }, + } + : undefined, }; } From ac0adced658e34f2c0e092a52b4510dc061d81f5 Mon Sep 17 00:00:00 2001 From: 116405 <116405@ky-tech.com.cn> Date: Sat, 30 May 2026 17:42:30 +0800 Subject: [PATCH 4/8] fix(web): rewrite relocated index artifact links --- apps/web/src/artifacts/html-links.ts | 1 - apps/web/tests/artifacts/html-links.test.ts | 16 ++++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/apps/web/src/artifacts/html-links.ts b/apps/web/src/artifacts/html-links.ts index 638142e5a..0eff1691d 100644 --- a/apps/web/src/artifacts/html-links.ts +++ b/apps/web/src/artifacts/html-links.ts @@ -147,7 +147,6 @@ function htmlFileFamilyKey(pathname: string): string | null { if (dot <= 0) return null; const stem = fileName.slice(0, dot); const ext = fileName.slice(dot).toLowerCase(); - if (stem.toLowerCase() === 'index') return null; const familyStem = stem.replace(/-\d+$/, ''); return `${directory}${familyStem}${ext}`.replace(/^\.\//, ''); } diff --git a/apps/web/tests/artifacts/html-links.test.ts b/apps/web/tests/artifacts/html-links.test.ts index dcc1e7360..9b6214516 100644 --- a/apps/web/tests/artifacts/html-links.test.ts +++ b/apps/web/tests/artifacts/html-links.test.ts @@ -100,6 +100,22 @@ describe('rewriteHtmlLinksToCurrentProjectFiles', () => { 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), + htmlFile('index-2.html', 40), + ]); + + expect(out).toContain('href="index-2.html"'); + expect(out).toContain('href="./index-2.html#top"'); + }); }); function htmlFile( From 9d29ef35d318efebaaee9f6dcb612a7b2d5350b5 Mon Sep 17 00:00:00 2001 From: 116405 <116405@ky-tech.com.cn> Date: Sat, 30 May 2026 20:26:27 +0800 Subject: [PATCH 5/8] fix(web): scope html artifact link rewrites --- apps/web/src/artifacts/html-links.ts | 24 +++++++++++++++++ apps/web/tests/artifacts/html-links.test.ts | 27 ++++++++++++++----- .../ProjectView.api-empty-response.test.tsx | 8 +++--- 3 files changed, 49 insertions(+), 10 deletions(-) diff --git a/apps/web/src/artifacts/html-links.ts b/apps/web/src/artifacts/html-links.ts index 0eff1691d..0ea693097 100644 --- a/apps/web/src/artifacts/html-links.ts +++ b/apps/web/src/artifacts/html-links.ts @@ -81,12 +81,21 @@ function buildLatestHtmlFileIndex(projectFiles: readonly HtmlLinkProjectFile[]): if (!isHtmlProjectFile(file)) continue; const key = htmlFileFamilyKey(file.name); if (!key) continue; + if (!htmlFileManifestMatchesFamily(file, key)) continue; const current = latest.get(key); if (!current || file.mtime > current.mtime) latest.set(key, file); } return new Map(Array.from(latest, ([key, file]) => [key, file.name])); } +function htmlFileManifestMatchesFamily(file: HtmlLinkProjectFile, familyKey: string): boolean { + const identifier = file.artifactManifest?.metadata?.identifier; + if (typeof identifier !== 'string') return false; + const normalizedIdentifier = normalizeArtifactIdentifier(identifier); + if (!normalizedIdentifier) return false; + return normalizedIdentifier === htmlFileFamilyIdentifier(familyKey); +} + function isHtmlProjectFile(file: HtmlLinkProjectFile): boolean { const name = file.name.toLowerCase(); return ( @@ -151,6 +160,21 @@ function htmlFileFamilyKey(pathname: string): string | null { 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 preserveRelativePrefix(originalPathname: string, latestName: string): string { if (originalPathname.startsWith('./') && !latestName.startsWith('./')) { return `./${latestName}`; diff --git a/apps/web/tests/artifacts/html-links.test.ts b/apps/web/tests/artifacts/html-links.test.ts index 9b6214516..c7ac2cf32 100644 --- a/apps/web/tests/artifacts/html-links.test.ts +++ b/apps/web/tests/artifacts/html-links.test.ts @@ -89,10 +89,10 @@ describe('rewriteHtmlLinksToCurrentProjectFiles', () => { ''; const out = rewriteHtmlLinksToCurrentProjectFiles(html, [ - htmlFile('about.html', 10), - htmlFile('about-2.html', 30), - htmlFile('contact.html', 20), - htmlFile('contact-2.html', 40), + htmlFile('about.html', 10, { artifactIdentifier: 'about' }), + htmlFile('about-2.html', 30, { artifactIdentifier: 'about' }), + htmlFile('contact.html', 20, { artifactIdentifier: 'contact' }), + htmlFile('contact-2.html', 40, { artifactIdentifier: 'contact' }), ]); expect(out).toContain('href="about-2.html"'); @@ -109,13 +109,28 @@ describe('rewriteHtmlLinksToCurrentProjectFiles', () => { ''; const out = rewriteHtmlLinksToCurrentProjectFiles(html, [ - htmlFile('index.html', 10), - htmlFile('index-2.html', 40), + htmlFile('index.html', 10, { artifactIdentifier: 'index' }), + htmlFile('index-2.html', 40, { artifactIdentifier: 'index' }), ]); 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' }), + htmlFile('about-2.html', 40, { artifactIdentifier: 'other-about' }), + ]); + + expect(out).toContain('href="about.html"'); + expect(out).not.toContain('href="about-2.html"'); + }); }); function htmlFile( 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 85f276a71..9f650f017 100644 --- a/apps/web/tests/components/ProjectView.api-empty-response.test.tsx +++ b/apps/web/tests/components/ProjectView.api-empty-response.test.tsx @@ -561,8 +561,8 @@ describe('ProjectView API empty response handling', () => { it('keeps regenerated multipage artifact entry at index.html and rewrites child links', async () => { mockedFetchProjectFiles.mockResolvedValue([ htmlProjectFile('index.html', 10, { artifactIdentifier: 'index' }), - htmlProjectFile('about.html', 20), - htmlProjectFile('about-2.html', 40), + 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 = @@ -596,8 +596,8 @@ describe('ProjectView API empty response handling', () => { 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), - htmlProjectFile('about-2.html', 40), + htmlProjectFile('about.html', 20, { artifactIdentifier: 'about' }), + htmlProjectFile('about-2.html', 40, { artifactIdentifier: 'about' }), ] as never); mockedWriteProjectTextFile.mockResolvedValue(htmlProjectFile('index-2.html', 50) as never); const artifact = From c48aee0aa66143cda542f03a48b74a33551f319f Mon Sep 17 00:00:00 2001 From: 116405 <116405@ky-tech.com.cn> Date: Sat, 30 May 2026 22:42:15 +0800 Subject: [PATCH 6/8] fix(web): isolate multipage html link groups --- apps/web/src/artifacts/html-links.ts | 38 +++++++++++++--- apps/web/src/components/ProjectView.tsx | 38 +++++++++++++++- apps/web/tests/artifacts/html-links.test.ts | 44 +++++++++++++------ .../ProjectView.api-empty-response.test.tsx | 32 +++++++++++--- 4 files changed, 126 insertions(+), 26 deletions(-) diff --git a/apps/web/src/artifacts/html-links.ts b/apps/web/src/artifacts/html-links.ts index 0ea693097..09ef39862 100644 --- a/apps/web/src/artifacts/html-links.ts +++ b/apps/web/src/artifacts/html-links.ts @@ -37,6 +37,10 @@ interface HtmlLinkProjectFile { }; } +interface HtmlLinkRewriteOptions { + artifactGroupIdentifier?: string; +} + export function canOverwriteHtmlArtifactEntry(input: { baseName: string; ext: '.html' | '.jsx' | '.tsx'; @@ -62,8 +66,9 @@ export function canOverwriteHtmlArtifactEntry(input: { export function rewriteHtmlLinksToCurrentProjectFiles( html: string, projectFiles: readonly HtmlLinkProjectFile[], + options: HtmlLinkRewriteOptions = {}, ): string { - const latestByTarget = buildLatestHtmlFileIndex(projectFiles); + const latestByTarget = buildLatestHtmlFileIndex(projectFiles, options); if (latestByTarget.size === 0) return html; return html.replace( @@ -75,25 +80,42 @@ export function rewriteHtmlLinksToCurrentProjectFiles( ); } -function buildLatestHtmlFileIndex(projectFiles: readonly HtmlLinkProjectFile[]): Map { +function buildLatestHtmlFileIndex( + projectFiles: readonly HtmlLinkProjectFile[], + options: HtmlLinkRewriteOptions, +): Map { const latest = new Map(); + const artifactGroupIdentifier = normalizeOptionalIdentifier(options.artifactGroupIdentifier); for (const file of projectFiles) { if (!isHtmlProjectFile(file)) continue; const key = htmlFileFamilyKey(file.name); if (!key) continue; - if (!htmlFileManifestMatchesFamily(file, key)) continue; + if (!htmlFileManifestMatchesFamily(file, key, artifactGroupIdentifier)) continue; const current = latest.get(key); if (!current || file.mtime > current.mtime) latest.set(key, file); } return new Map(Array.from(latest, ([key, file]) => [key, file.name])); } -function htmlFileManifestMatchesFamily(file: HtmlLinkProjectFile, familyKey: string): boolean { +function htmlFileManifestMatchesFamily( + file: HtmlLinkProjectFile, + familyKey: string, + artifactGroupIdentifier: string | null, +): boolean { const identifier = file.artifactManifest?.metadata?.identifier; if (typeof identifier !== 'string') return false; const normalizedIdentifier = normalizeArtifactIdentifier(identifier); if (!normalizedIdentifier) return false; - return normalizedIdentifier === htmlFileFamilyIdentifier(familyKey); + if (normalizedIdentifier !== htmlFileFamilyIdentifier(familyKey)) return false; + + const fileGroupIdentifier = normalizeOptionalIdentifier( + file.artifactManifest?.metadata?.artifactGroupIdentifier, + ); + return ( + artifactGroupIdentifier !== null && + fileGroupIdentifier !== null && + artifactGroupIdentifier === fileGroupIdentifier + ); } function isHtmlProjectFile(file: HtmlLinkProjectFile): boolean { @@ -175,6 +197,12 @@ function normalizeArtifactIdentifier(value: string): string { .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}`; diff --git a/apps/web/src/components/ProjectView.tsx b/apps/web/src/components/ProjectView.tsx index c6e921129..1028a9a7a 100644 --- a/apps/web/src/components/ProjectView.tsx +++ b/apps/web/src/components/ProjectView.tsx @@ -1157,9 +1157,15 @@ export function ProjectView({ artifactIdentifier: art.identifier, }), }); + const artifactGroupIdentifier = + ext === '.html' + ? htmlArtifactGroupIdentifierFor(art, currentProjectFiles, fileName) + : undefined; const html = ext === '.html' - ? rewriteHtmlLinksToCurrentProjectFiles(art.html, currentProjectFiles) + ? rewriteHtmlLinksToCurrentProjectFiles(art.html, currentProjectFiles, { + artifactGroupIdentifier, + }) : art.html; if (ext === '.html') { const pointerTarget = resolveHtmlPointerArtifactTarget({ @@ -1193,6 +1199,7 @@ export function ProjectView({ identifier: art.identifier, artifactType: art.artifactType, inferred: false, + ...(artifactGroupIdentifier ? { artifactGroupIdentifier } : {}), }; const manifest = ext === '.html' @@ -4629,6 +4636,35 @@ function artifactBaseNameFor(art: Artifact): string { ); } +function htmlArtifactGroupIdentifierFor( + art: Artifact, + projectFiles: ProjectFile[], + fileName: string, +): string { + 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 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 index c7ac2cf32..5298331df 100644 --- a/apps/web/tests/artifacts/html-links.test.ts +++ b/apps/web/tests/artifacts/html-links.test.ts @@ -89,11 +89,11 @@ describe('rewriteHtmlLinksToCurrentProjectFiles', () => { ''; const out = rewriteHtmlLinksToCurrentProjectFiles(html, [ - htmlFile('about.html', 10, { artifactIdentifier: 'about' }), - htmlFile('about-2.html', 30, { artifactIdentifier: 'about' }), - htmlFile('contact.html', 20, { artifactIdentifier: 'contact' }), - htmlFile('contact-2.html', 40, { artifactIdentifier: 'contact' }), - ]); + 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"'); @@ -109,9 +109,9 @@ describe('rewriteHtmlLinksToCurrentProjectFiles', () => { ''; const out = rewriteHtmlLinksToCurrentProjectFiles(html, [ - htmlFile('index.html', 10, { artifactIdentifier: 'index' }), - htmlFile('index-2.html', 40, { artifactIdentifier: 'index' }), - ]); + 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"'); @@ -124,9 +124,24 @@ describe('rewriteHtmlLinksToCurrentProjectFiles', () => { ''; const out = rewriteHtmlLinksToCurrentProjectFiles(html, [ - htmlFile('about.html', 10, { artifactIdentifier: 'about' }), - htmlFile('about-2.html', 40, { artifactIdentifier: 'other-about' }), - ]); + 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"'); @@ -136,7 +151,7 @@ describe('rewriteHtmlLinksToCurrentProjectFiles', () => { function htmlFile( name: string, mtime: number, - options: { artifactIdentifier?: string } = {}, + options: { artifactIdentifier?: string; artifactGroupIdentifier?: string } = {}, ) { return { name, @@ -147,7 +162,10 @@ function htmlFile( artifactManifest: options.artifactIdentifier !== undefined ? { entry: name, - metadata: { identifier: options.artifactIdentifier }, + 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 9f650f017..04194a529 100644 --- a/apps/web/tests/components/ProjectView.api-empty-response.test.tsx +++ b/apps/web/tests/components/ProjectView.api-empty-response.test.tsx @@ -560,9 +560,18 @@ describe('ProjectView API empty response handling', () => { it('keeps regenerated multipage artifact entry at index.html and rewrites child links', async () => { mockedFetchProjectFiles.mockResolvedValue([ - htmlProjectFile('index.html', 10, { artifactIdentifier: 'index' }), - htmlProjectFile('about.html', 20, { artifactIdentifier: 'about' }), - htmlProjectFile('about-2.html', 40, { artifactIdentifier: 'about' }), + 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 = @@ -596,8 +605,14 @@ describe('ProjectView API empty response handling', () => { 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' }), - htmlProjectFile('about-2.html', 40, { artifactIdentifier: 'about' }), + 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 = @@ -767,7 +782,7 @@ function hasSavedAssistantMessage(predicate: (message: ChatMessage) => boolean): function htmlProjectFile( name: string, mtime: number, - options: { artifactIdentifier?: string } = {}, + options: { artifactIdentifier?: string; artifactGroupIdentifier?: string } = {}, ) { return { name, @@ -786,7 +801,10 @@ function htmlProjectFile( status: 'complete', exports: ['html'], primary: true, - metadata: { identifier: options.artifactIdentifier }, + metadata: { + identifier: options.artifactIdentifier, + artifactGroupIdentifier: options.artifactGroupIdentifier, + }, } : undefined, }; From 361c18a4142d0c7ddfe198d7ea5cef902ec011d6 Mon Sep 17 00:00:00 2001 From: 116405 <116405@ky-tech.com.cn> Date: Sat, 30 May 2026 23:22:39 +0800 Subject: [PATCH 7/8] fix(web): support legacy html artifact rewrites --- apps/web/src/artifacts/html-links.ts | 59 +++++++++++++------ apps/web/tests/artifacts/html-links.test.ts | 34 +++++++++++ .../ProjectView.api-empty-response.test.tsx | 38 ++++++++++++ 3 files changed, 113 insertions(+), 18 deletions(-) diff --git a/apps/web/src/artifacts/html-links.ts b/apps/web/src/artifacts/html-links.ts index 09ef39862..72df433f1 100644 --- a/apps/web/src/artifacts/html-links.ts +++ b/apps/web/src/artifacts/html-links.ts @@ -84,38 +84,61 @@ function buildLatestHtmlFileIndex( projectFiles: readonly HtmlLinkProjectFile[], options: HtmlLinkRewriteOptions, ): Map { - const latest = new 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; - if (!htmlFileManifestMatchesFamily(file, key, artifactGroupIdentifier)) continue; - const current = latest.get(key); - if (!current || file.mtime > current.mtime) latest.set(key, file); + 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 htmlFileManifestMatchesFamily( +function htmlFileManifestFamilyMatch( file: HtmlLinkProjectFile, familyKey: string, - artifactGroupIdentifier: string | null, -): boolean { +): { artifactGroupIdentifier: string | null } | null { const identifier = file.artifactManifest?.metadata?.identifier; - if (typeof identifier !== 'string') return false; + if (typeof identifier !== 'string') return null; const normalizedIdentifier = normalizeArtifactIdentifier(identifier); - if (!normalizedIdentifier) return false; - if (normalizedIdentifier !== htmlFileFamilyIdentifier(familyKey)) return false; + if (!normalizedIdentifier) return null; + if (normalizedIdentifier !== htmlFileFamilyIdentifier(familyKey)) return null; - const fileGroupIdentifier = normalizeOptionalIdentifier( - file.artifactManifest?.metadata?.artifactGroupIdentifier, - ); - return ( - artifactGroupIdentifier !== null && - fileGroupIdentifier !== null && - artifactGroupIdentifier === fileGroupIdentifier - ); + return { + artifactGroupIdentifier: normalizeOptionalIdentifier( + file.artifactManifest?.metadata?.artifactGroupIdentifier, + ), + }; } function isHtmlProjectFile(file: HtmlLinkProjectFile): boolean { diff --git a/apps/web/tests/artifacts/html-links.test.ts b/apps/web/tests/artifacts/html-links.test.ts index 5298331df..b0d06a593 100644 --- a/apps/web/tests/artifacts/html-links.test.ts +++ b/apps/web/tests/artifacts/html-links.test.ts @@ -146,6 +146,40 @@ describe('rewriteHtmlLinksToCurrentProjectFiles', () => { 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( 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 04194a529..ea15d0dea 100644 --- a/apps/web/tests/components/ProjectView.api-empty-response.test.tsx +++ b/apps/web/tests/components/ProjectView.api-empty-response.test.tsx @@ -643,6 +643,44 @@ describe('ProjectView API empty response handling', () => { expect(content).toContain('href="about-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); From 4ecdb4a15c99301ec681da789a8e26a9c39ff7c6 Mon Sep 17 00:00:00 2001 From: 116405 <116405@ky-tech.com.cn> Date: Sun, 31 May 2026 00:15:25 +0800 Subject: [PATCH 8/8] fix(web): avoid reusing unrelated html artifact groups --- apps/web/src/components/ProjectView.tsx | 61 +++++++++++++------ .../ProjectView.api-empty-response.test.tsx | 48 +++++++++++++++ 2 files changed, 89 insertions(+), 20 deletions(-) diff --git a/apps/web/src/components/ProjectView.tsx b/apps/web/src/components/ProjectView.tsx index 1028a9a7a..4daac7d89 100644 --- a/apps/web/src/components/ProjectView.tsx +++ b/apps/web/src/components/ProjectView.tsx @@ -1144,22 +1144,26 @@ export function ProjectView({ // keeping index.html stable for multi-page HTML artifacts. const currentProjectFiles = projectFilesSnapshot ?? projectFilesRef.current; const existing = new Set(currentProjectFiles.map((f) => f.name)); + const canOverwriteExistingEntry = canOverwriteHtmlArtifactEntry({ + baseName, + ext, + projectFiles: currentProjectFiles, + savedArtifactName: savedArtifactRef.current, + artifactIdentifier: art.identifier, + }); const fileName = resolveHtmlArtifactFileName({ baseName, ext, existingFileNames: existing, savedArtifactName: savedArtifactRef.current, - canOverwriteExistingEntry: canOverwriteHtmlArtifactEntry({ - baseName, - ext, - projectFiles: currentProjectFiles, - savedArtifactName: savedArtifactRef.current, - artifactIdentifier: art.identifier, - }), + canOverwriteExistingEntry, }); const artifactGroupIdentifier = ext === '.html' - ? htmlArtifactGroupIdentifierFor(art, currentProjectFiles, fileName) + ? htmlArtifactGroupIdentifierFor(art, currentProjectFiles, fileName, { + canReuseExistingGroup: + canOverwriteExistingEntry || savedArtifactRef.current === fileName, + }) : undefined; const html = ext === '.html' @@ -4640,23 +4644,40 @@ function htmlArtifactGroupIdentifierFor( art: Artifact, projectFiles: ProjectFile[], fileName: string, + options: { canReuseExistingGroup: boolean }, ): string { - 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; + 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 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 ea15d0dea..10bca076e 100644 --- a/apps/web/tests/components/ProjectView.api-empty-response.test.tsx +++ b/apps/web/tests/components/ProjectView.api-empty-response.test.tsx @@ -643,6 +643,54 @@ describe('ProjectView API empty response handling', () => { 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' }),