diff --git a/desktop/src/components/chat/CurrentTurnChangeCard.test.tsx b/desktop/src/components/chat/CurrentTurnChangeCard.test.tsx index b1b24c7a..f0733abe 100644 --- a/desktop/src/components/chat/CurrentTurnChangeCard.test.tsx +++ b/desktop/src/components/chat/CurrentTurnChangeCard.test.tsx @@ -280,7 +280,7 @@ describe('CurrentTurnChangeCard – open-with buttons', () => { expect(openPreviewSpy).toHaveBeenCalledWith('s1', 'README.md', 'file') }) - it('clicking index.html open-with opens menu with in-app browser item', async () => { + it('clicking project index.html open-with opens menu with workspace preview item', async () => { renderCard(['/w/proj/index.html']) const [openWithBtn] = screen.getAllByRole('button', { name: 'openWith.title' }) @@ -288,6 +288,18 @@ describe('CurrentTurnChangeCard – open-with buttons', () => { fireEvent.click(openWithBtn!) }) + expect(await screen.findByText('openWith.workspacePreview')).toBeInTheDocument() + expect(screen.queryByText('openWith.inAppBrowser')).not.toBeInTheDocument() + }) + + it('clicking built dist index.html open-with opens menu with in-app browser item', async () => { + renderCard(['/w/proj/dist/index.html']) + const [openWithBtn] = screen.getAllByRole('button', { name: 'openWith.title' }) + + await act(async () => { + fireEvent.click(openWithBtn!) + }) + expect(await screen.findByText('openWith.inAppBrowser')).toBeInTheDocument() }) diff --git a/desktop/src/lib/handlePreviewLink.test.ts b/desktop/src/lib/handlePreviewLink.test.ts index 39fb2c84..78d55496 100644 --- a/desktop/src/lib/handlePreviewLink.test.ts +++ b/desktop/src/lib/handlePreviewLink.test.ts @@ -52,7 +52,7 @@ describe('handlePreviewLink', () => { ) }) - it('routes a relative .html file to openBrowser with the preview-fs (workspace) url', () => { + it('routes a built relative .html file to openBrowser with the preview-fs (workspace) url', () => { const deps = makeDeps() const handled = handlePreviewLink('out/index.html', deps) expect(handled).toBe(true) @@ -64,6 +64,14 @@ describe('handlePreviewLink', () => { expect(deps.openFilePreview).not.toHaveBeenCalled() }) + it('routes a frontend project index.html to workspace preview instead of static browser preview', () => { + const deps = makeDeps() + const handled = handlePreviewLink('todo-app/index.html', deps) + expect(handled).toBe(true) + expect(deps.openFilePreview).toHaveBeenCalledWith('s1', 'todo-app/index.html') + expect(deps.openBrowser).not.toHaveBeenCalled() + }) + it('routes relative previewable docs to openFilePreview with the relative path', () => { const deps = makeDeps() const handled = handlePreviewLink('docs/report.md', deps) diff --git a/desktop/src/lib/handlePreviewLink.ts b/desktop/src/lib/handlePreviewLink.ts index 5e863227..b66eea53 100644 --- a/desktop/src/lib/handlePreviewLink.ts +++ b/desktop/src/lib/handlePreviewLink.ts @@ -1,4 +1,5 @@ import { classifyPreviewLink } from './previewLinkRouter' +import { shouldOfferStaticHtmlPreview } from './htmlPreviewPolicy' export type PreviewLinkDeps = { sessionId: string @@ -61,6 +62,10 @@ export function handlePreviewLink(href: string, deps: PreviewLinkDeps): boolean // Absolute paths (incl. file:// → absolute) may live OUTSIDE the session // workspace, so serve them via the $HOME-sandboxed /local-file route. // Relative paths stay workspace-scoped via /preview-fs. + if (!isAbsoluteLocalPath(filePath) && !shouldOfferStaticHtmlPreview(filePath)) { + deps.openFilePreview(deps.sessionId, filePath) + return true + } const url = isAbsoluteLocalPath(filePath) ? localFileUrl(deps.serverBaseUrl, filePath) : previewFsUrl(deps.serverBaseUrl, deps.sessionId, filePath) diff --git a/desktop/src/lib/htmlPreviewPolicy.ts b/desktop/src/lib/htmlPreviewPolicy.ts new file mode 100644 index 00000000..fa81dd1e --- /dev/null +++ b/desktop/src/lib/htmlPreviewPolicy.ts @@ -0,0 +1,36 @@ +const HTML_EXT = /\.(html?|xhtml)$/i +const INDEX_HTML_RE = /(^|\/)index\.html?$/i + +const STATIC_OUTPUT_DIRS = new Set([ + 'build', + 'coverage', + 'dist', + 'docs', + 'lcov-report', + 'out', + 'public', + 'site', + 'storybook-static', +]) + +function normalizePathForPolicy(filePath: string): string { + return filePath + .split(/[?#]/, 1)[0]! + .replace(/\\/g, '/') + .replace(/^\.\//, '') +} + +export function isHtmlFilePath(filePath: string): boolean { + return HTML_EXT.test(normalizePathForPolicy(filePath)) +} + +export function shouldOfferStaticHtmlPreview(filePath: string): boolean { + const normalized = normalizePathForPolicy(filePath) + if (!HTML_EXT.test(normalized)) return false + if (!INDEX_HTML_RE.test(normalized)) return true + + return normalized + .split('/') + .filter(Boolean) + .some((segment) => STATIC_OUTPUT_DIRS.has(segment.toLowerCase())) +} diff --git a/desktop/src/lib/openWithContextForHref.test.ts b/desktop/src/lib/openWithContextForHref.test.ts index 5787648b..436e01ca 100644 --- a/desktop/src/lib/openWithContextForHref.test.ts +++ b/desktop/src/lib/openWithContextForHref.test.ts @@ -58,14 +58,24 @@ describe('openWithContextForWorkspaceFile', () => { expect(result).toEqual({ kind: 'file', absolutePath: '/w/proj/README.md', relPath: 'README.md', previewable: true }) }) - it('index.html rel path → also has inAppBrowserUrl equal to previewFsUrl', () => { - const result = openWithContextForWorkspaceFile('index.html', '/w/proj/index.html', { sessionId: SESSION, serverBaseUrl: BASE }) + it('frontend project index.html rel path → no inAppBrowserUrl because it needs a dev server', () => { + const result = openWithContextForWorkspaceFile('todo-app/index.html', '/w/proj/todo-app/index.html', { sessionId: SESSION, serverBaseUrl: BASE }) expect(result).toEqual({ kind: 'file', - absolutePath: '/w/proj/index.html', - relPath: 'index.html', + absolutePath: '/w/proj/todo-app/index.html', + relPath: 'todo-app/index.html', previewable: true, - inAppBrowserUrl: previewFsUrl(BASE, SESSION, 'index.html'), + }) + }) + + it('built dist index.html rel path → has inAppBrowserUrl equal to previewFsUrl', () => { + const result = openWithContextForWorkspaceFile('todo-app/dist/index.html', '/w/proj/todo-app/dist/index.html', { sessionId: SESSION, serverBaseUrl: BASE }) + expect(result).toEqual({ + kind: 'file', + absolutePath: '/w/proj/todo-app/dist/index.html', + relPath: 'todo-app/dist/index.html', + previewable: true, + inAppBrowserUrl: previewFsUrl(BASE, SESSION, 'todo-app/dist/index.html'), }) }) diff --git a/desktop/src/lib/openWithContextForHref.ts b/desktop/src/lib/openWithContextForHref.ts index 123e02a0..28065b4a 100644 --- a/desktop/src/lib/openWithContextForHref.ts +++ b/desktop/src/lib/openWithContextForHref.ts @@ -1,9 +1,8 @@ import { classifyPreviewLink } from './previewLinkRouter' +import { shouldOfferStaticHtmlPreview } from './htmlPreviewPolicy' import { isAbsoluteLocalPath, localFileUrl, previewFsUrl } from './handlePreviewLink' import type { OpenWithContext } from './openWithItems' -const HTML_EXT = /\.(html?|xhtml)$/i - /** Build an open-with context for a workspace file (we have both its relative + absolute path). */ export function openWithContextForWorkspaceFile( relPath: string, @@ -15,7 +14,7 @@ export function openWithContextForWorkspaceFile( absolutePath, relPath, previewable: true, - inAppBrowserUrl: HTML_EXT.test(relPath) ? previewFsUrl(opts.serverBaseUrl, opts.sessionId, relPath) : undefined, + inAppBrowserUrl: shouldOfferStaticHtmlPreview(relPath) ? previewFsUrl(opts.serverBaseUrl, opts.sessionId, relPath) : undefined, } } @@ -38,10 +37,14 @@ export function openWithContextForHref( if (c.kind === 'browser-file' && c.path) { // Absolute paths may be outside the session workspace → serve via the // $HOME-sandboxed /local-file route; relative paths stay workspace-scoped. - const inAppBrowserUrl = isAbsoluteLocalPath(c.path) - ? localFileUrl(opts.serverBaseUrl, c.path) - : previewFsUrl(opts.serverBaseUrl, opts.sessionId, c.path) - return { kind: 'file', absolutePath: resolveAbsolute(opts.workDir, c.path), inAppBrowserUrl } + const absolutePath = resolveAbsolute(opts.workDir, c.path) + if (isAbsoluteLocalPath(c.path)) { + return { kind: 'file', absolutePath, inAppBrowserUrl: localFileUrl(opts.serverBaseUrl, c.path) } + } + if (shouldOfferStaticHtmlPreview(c.path)) { + return { kind: 'file', absolutePath, inAppBrowserUrl: previewFsUrl(opts.serverBaseUrl, opts.sessionId, c.path) } + } + return { kind: 'file', absolutePath, relPath: c.path, previewable: true } } return null } diff --git a/src/server/api/__tests__/previewFs.test.ts b/src/server/api/__tests__/previewFs.test.ts index bcb53224..def813df 100644 --- a/src/server/api/__tests__/previewFs.test.ts +++ b/src/server/api/__tests__/previewFs.test.ts @@ -35,6 +35,13 @@ function setupWorkspace() { writeFileSync(path.join(root, 'index.html'), '