Skip to content

Commit 2f91ab3

Browse files
committed
fix(hub): render JSX thumbnails through babel runtime even when source contains <html>
needsJsxRuntime was returning false for any source containing <html> or <!doctype>, but agent-generated JSX regularly wraps its output in a function App() { return <html>...</html> }. The raw-HTML fast-path then injected the JSX module verbatim as srcDoc, and the iframe rendered function/const declarations as plain text alongside partially-parsed tags — the thumbnail showed half rendered layout, half naked source. Priority flipped: EDITMODE-BEGIN / ReactDOM.createRoot / function App are strong agent-only signals and now win over the HTML tag fast-path. Added regression test covering the exact shape from the bug report. Export the helper so the test can reach it without a round-trip through the full component.
1 parent 6aad828 commit 2f91ab3

5 files changed

Lines changed: 90 additions & 16 deletions

File tree

apps/desktop/src/main/index.ts

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -765,15 +765,31 @@ function registerIpcHandlers(): void {
765765
}
766766
const allowKeyless = active.allowKeyless;
767767
const baseUrl = active.baseUrl ?? undefined;
768-
return generateTitle({
769-
prompt,
770-
model: active.model,
771-
apiKey,
772-
...(baseUrl !== undefined ? { baseUrl } : {}),
773-
wire: active.wire,
774-
...(active.httpHeaders !== undefined ? { httpHeaders: active.httpHeaders } : {}),
775-
...(allowKeyless ? { allowKeyless: true } : {}),
776-
});
768+
const titleLogger: CoreLogger = {
769+
info: (event, data) => logIpc.info(event, data),
770+
error: (event, data) => logIpc.error(event, data),
771+
};
772+
try {
773+
return await generateTitle({
774+
prompt,
775+
model: active.model,
776+
apiKey,
777+
...(baseUrl !== undefined ? { baseUrl } : {}),
778+
wire: active.wire,
779+
...(active.httpHeaders !== undefined ? { httpHeaders: active.httpHeaders } : {}),
780+
...(allowKeyless ? { allowKeyless: true } : {}),
781+
logger: titleLogger,
782+
});
783+
} catch (err) {
784+
logIpc.error('[title] generate-title.fail', {
785+
provider: active.model.provider,
786+
modelId: active.model.modelId,
787+
baseUrl,
788+
message: err instanceof Error ? err.message : String(err),
789+
code: err instanceof CodesignError ? err.code : undefined,
790+
});
791+
throw err;
792+
}
777793
});
778794

779795
ipcMain.handle('codesign:open-log-folder', async () => {

apps/desktop/src/renderer/src/store.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import type {
1717
import { create } from 'zustand';
1818
import type { StoreApi } from 'zustand';
1919
import type { CodesignApi, ExportFormat } from '../../preload/index';
20+
import { rendererLogger } from './lib/renderer-logger';
2021

2122
declare global {
2223
interface Window {
@@ -661,9 +662,14 @@ async function maybeAutoRename(
661662
const trimmed = generated.trim();
662663
if (trimmed.length > 0) newName = trimmed;
663664
}
664-
} catch {
665+
} catch (err) {
665666
// Fall through to the truncation fallback — don't surface a toast; the
666667
// name itself is a nice-to-have and the user can always rename manually.
668+
// But DO log the failure so we can see why in the main-process log.
669+
rendererLogger.warn('store', '[title] generateTitle failed, using prompt fallback', {
670+
designId,
671+
message: err instanceof Error ? err.message : String(err),
672+
});
667673
}
668674
try {
669675
await window.codesign.snapshots.renameDesign(designId, newName);
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { needsJsxRuntime } from './DesignCardPreview';
3+
4+
describe('needsJsxRuntime', () => {
5+
it('returns true for JSX that also contains <html> inside a return block', () => {
6+
// Regression for hub thumbnails rendering JSX source as plain text.
7+
// The `<html>` inside a `function App() { return <html>...</html> }` body
8+
// used to trigger the raw-HTML fast-path and bypass the babel runtime.
9+
const jsxWithHtmlTag = `const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{"a":1}/*EDITMODE-END*/;
10+
function App() {
11+
return (
12+
<html>
13+
<body><h1>Hi</h1></body>
14+
</html>
15+
);
16+
}
17+
ReactDOM.createRoot(document.getElementById('root')).render(<App/>);`;
18+
expect(needsJsxRuntime(jsxWithHtmlTag)).toBe(true);
19+
});
20+
21+
it('returns true for EDITMODE-marked JSX without <html>', () => {
22+
expect(
23+
needsJsxRuntime(
24+
`const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{"accent":"#000"}/*EDITMODE-END*/;\nfunction App(){ return <div/>; }`,
25+
),
26+
).toBe(true);
27+
});
28+
29+
it('returns false for a real HTML document', () => {
30+
expect(
31+
needsJsxRuntime('<!doctype html>\n<html><head><title>x</title></head><body>hi</body></html>'),
32+
).toBe(false);
33+
});
34+
35+
it('returns false for plain body-only HTML with no JSX markers', () => {
36+
expect(needsJsxRuntime('<div>hello</div>')).toBe(false);
37+
});
38+
39+
it('returns true for ReactDOM.createRoot even without EDITMODE marker', () => {
40+
expect(
41+
needsJsxRuntime(
42+
`function App(){return <p/>;}\nReactDOM.createRoot(document.getElementById('root')).render(<App/>);`,
43+
),
44+
).toBe(true);
45+
});
46+
});

apps/desktop/src/renderer/src/views/hub/DesignCardPreview.tsx

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,13 +30,19 @@ function injectThumbnailStyle(srcDoc: string): string {
3030
}
3131

3232
// Lightweight JSX detection — mirrors runtime's isJsxArtifact without importing it.
33-
function needsJsxRuntime(source: string): boolean {
34-
if (/<!doctype/i.test(source) || /<html[^>]*>/i.test(source)) return false;
35-
return (
33+
// JSX markers take priority over HTML tags: our agent always emits
34+
// `EDITMODE-BEGIN` (and often `ReactDOM.createRoot`) in JSX artifacts, and the
35+
// JSX body itself commonly includes `<html>`/`<head>` tags inside a
36+
// `function App(){ return <html>...</html> }` return. Checking HTML first made
37+
// every JSX thumbnail render as raw source text in the hub grid.
38+
export function needsJsxRuntime(source: string): boolean {
39+
const hasJsxMarker =
3640
/EDITMODE-BEGIN/.test(source) ||
3741
/ReactDOM\.createRoot\s*\(/.test(source) ||
38-
/^\s*function\s+App\s*\(/m.test(source)
39-
);
42+
/^\s*function\s+App\s*\(/m.test(source);
43+
if (hasJsxMarker) return true;
44+
if (/<!doctype/i.test(source) || /<html[^>]*>/i.test(source)) return false;
45+
return false;
4046
}
4147

4248
export interface DesignCardPreviewProps {

packages/core/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -764,7 +764,7 @@ export async function generateTitle(input: GenerateTitleInput): Promise<string>
764764
...(input.httpHeaders !== undefined ? { httpHeaders: input.httpHeaders } : {}),
765765
...(input.allowKeyless === true ? { allowKeyless: true } : {}),
766766
...(input.signal !== undefined ? { signal: input.signal } : {}),
767-
maxTokens: 60,
767+
maxTokens: 200,
768768
});
769769
log.info('[title] step=send_request.ok', { ms: Date.now() - started });
770770
const title = sanitizeTitle(result.content);

0 commit comments

Comments
 (0)