co-mono/packages/web-ui/src/tools/artifacts/HtmlArtifact.ts
Mario Zechner 4ac774cbbb Fix console logging and message routing
Console Logging Improvements:
- Changed ConsoleRuntimeProvider to send logs immediately instead of batching
- Track pending send promises and await them in onCompleted callback
- Ensures REPL gets all logs before execution-complete
- Enables real-time console logging for HTML artifacts

Message Routing Fixes:
- Remove "handled" concept from message routing - broadcast all messages to all providers/consumers
- Change handleMessage return type from Promise<boolean> to Promise<void>
- Add debug logging to RuntimeMessageRouter to trace message flow
- Fix duplicate error logging (window error handler now only tracks errors, doesn't log them)

Output Formatting Consistency:
- Remove [LOG], [ERROR] prefixes from console output in both tools
- Show console logs before error messages
- Use "=> value" format for return values in both javascript-repl and browser-javascript
- Remove duplicate "Error:" prefix and extra formatting

Bug Fixes:
- Fix race condition where execution-complete arrived before console logs
- Fix ConsoleRuntimeProvider blocking execution-complete from reaching consumers
- Remove duplicate console log collection from SandboxedIframe
- Fix return value capture by wrapping user code in async function
2025-10-09 23:20:14 +02:00

164 lines
5.6 KiB
TypeScript

import { CopyButton, DownloadButton, PreviewCodeToggle } from "@mariozechner/mini-lit";
import hljs from "highlight.js";
import { html } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { createRef, type Ref, ref } from "lit/directives/ref.js";
import { unsafeHTML } from "lit/directives/unsafe-html.js";
import type { SandboxIframe } from "../../components/SandboxedIframe.js";
import { type MessageConsumer, RUNTIME_MESSAGE_ROUTER } from "../../components/sandbox/RuntimeMessageRouter.js";
import type { SandboxRuntimeProvider } from "../../components/sandbox/SandboxRuntimeProvider.js";
import { i18n } from "../../utils/i18n.js";
import "../../components/SandboxedIframe.js";
import { ArtifactElement } from "./ArtifactElement.js";
import type { Console } from "./Console.js";
import "./Console.js";
@customElement("html-artifact")
export class HtmlArtifact extends ArtifactElement {
@property() override filename = "";
@property({ attribute: false }) runtimeProviders: SandboxRuntimeProvider[] = [];
@property({ attribute: false }) sandboxUrlProvider?: () => string;
private _content = "";
private logs: Array<{ type: "log" | "error"; text: string }> = [];
// Refs for DOM elements
private sandboxIframeRef: Ref<SandboxIframe> = createRef();
private consoleRef: Ref<Console> = createRef();
@state() private viewMode: "preview" | "code" = "preview";
private setViewMode(mode: "preview" | "code") {
this.viewMode = mode;
}
public getHeaderButtons() {
const toggle = new PreviewCodeToggle();
toggle.mode = this.viewMode;
toggle.addEventListener("mode-change", (e: Event) => {
this.setViewMode((e as CustomEvent).detail);
});
const copyButton = new CopyButton();
copyButton.text = this._content;
copyButton.title = i18n("Copy HTML");
copyButton.showText = false;
// Generate standalone HTML with all runtime code injected for download
const sandbox = this.sandboxIframeRef.value;
const sandboxId = `artifact-${this.filename}`;
const downloadContent =
sandbox?.prepareHtmlDocument(sandboxId, this._content, this.runtimeProviders || [], true) || this._content;
return html`
<div class="flex items-center gap-2">
${toggle}
${copyButton}
${DownloadButton({ content: downloadContent, filename: this.filename, mimeType: "text/html", title: i18n("Download HTML") })}
</div>
`;
}
override set content(value: string) {
const oldValue = this._content;
this._content = value;
if (oldValue !== value) {
// Reset logs when content changes
this.logs = [];
this.requestUpdate();
// Execute content in sandbox if it exists
if (this.sandboxIframeRef.value && value) {
this.executeContent(value);
}
}
}
private executeContent(html: string) {
const sandbox = this.sandboxIframeRef.value;
if (!sandbox) return;
// Configure sandbox URL provider if provided (for browser extensions)
if (this.sandboxUrlProvider) {
sandbox.sandboxUrlProvider = this.sandboxUrlProvider;
}
const sandboxId = `artifact-${this.filename}`;
// Create consumer for console messages
const consumer: MessageConsumer = {
handleMessage: async (message: any): Promise<void> => {
if (message.type === "console") {
// Create new array reference for Lit reactivity
this.logs = [
...this.logs,
{
type: message.method === "error" ? "error" : "log",
text: message.text,
},
];
this.requestUpdate(); // Re-render to show console
}
},
};
// Load content - this handles sandbox registration, consumer registration, and iframe creation
sandbox.loadContent(sandboxId, html, this.runtimeProviders, [consumer]);
}
override get content(): string {
return this._content;
}
override disconnectedCallback() {
super.disconnectedCallback();
// Unregister sandbox when element is removed from DOM
const sandboxId = `artifact-${this.filename}`;
RUNTIME_MESSAGE_ROUTER.unregisterSandbox(sandboxId);
}
override firstUpdated() {
// Execute initial content
if (this._content && this.sandboxIframeRef.value) {
this.executeContent(this._content);
}
}
override updated(changedProperties: Map<string | number | symbol, unknown>) {
super.updated(changedProperties);
// If we have content but haven't executed yet (e.g., during reconstruction),
// execute when the iframe ref becomes available
if (this._content && this.sandboxIframeRef.value && this.logs.length === 0) {
this.executeContent(this._content);
}
}
public getLogs(): string {
if (this.logs.length === 0) return i18n("No logs for {filename}").replace("{filename}", this.filename);
return this.logs.map((l) => `[${l.type}] ${l.text}`).join("\n");
}
override render() {
return html`
<div class="h-full flex flex-col">
<div class="flex-1 overflow-hidden relative">
<!-- Preview container - always in DOM, just hidden when not active -->
<div class="absolute inset-0 flex flex-col" style="display: ${this.viewMode === "preview" ? "flex" : "none"}">
<sandbox-iframe class="flex-1" ${ref(this.sandboxIframeRef)}></sandbox-iframe>
${
this.logs.length > 0
? html`<artifact-console .logs=${this.logs} ${ref(this.consoleRef)}></artifact-console>`
: ""
}
</div>
<!-- Code view - always in DOM, just hidden when not active -->
<div class="absolute inset-0 overflow-auto bg-background" style="display: ${this.viewMode === "code" ? "block" : "none"}">
<pre class="m-0 p-4 text-xs"><code class="hljs language-html">${unsafeHTML(
hljs.highlight(this._content, { language: "html" }).value,
)}</code></pre>
</div>
</div>
</div>
`;
}
}