Fix HtmlArtifacts no loading/updating

This commit is contained in:
Mario Zechner 2025-10-03 11:35:12 +02:00
parent 79dd23b6da
commit 6d046236bf
10 changed files with 2289 additions and 97 deletions

View file

@ -1,13 +1,15 @@
import { html } from "@mariozechner/mini-lit";
import { Badge, html } from "@mariozechner/mini-lit";
import { getModel } from "@mariozechner/pi-ai";
import { LitElement } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import "./components/AgentInterface.js";
import { AgentSession } from "./state/agent-session.js";
import { AgentSession, type AgentSessionState, type ThinkingLevel } from "./state/agent-session.js";
import { ArtifactsPanel } from "./tools/artifacts/index.js";
import { browserJavaScriptTool, createJavaScriptReplTool } from "./tools/index.js";
import { registerToolRenderer } from "./tools/renderer-registry.js";
import { getAuthToken } from "./utils/auth-token.js";
import { i18n } from "./utils/i18n.js";
import { longSession, simpleHtml } from "./utils/test-sessions.js";
const BREAKPOINT = 800; // px - switch between overlay and side-by-side
@ -17,7 +19,7 @@ export class ChatPanel extends LitElement {
@state() private artifactsPanel!: ArtifactsPanel;
@state() private hasArtifacts = false;
@state() private artifactCount = 0;
@state() private showArtifactsPanel = true;
@state() private showArtifactsPanel = false;
@state() private windowWidth = window.innerWidth;
@property({ type: String }) systemPrompt = "You are a helpful AI assistant.";
@ -98,17 +100,26 @@ export class ChatPanel extends LitElement {
this.requestUpdate();
};
// Create agent session with default settings
let initialState = {
systemPrompt: this.systemPrompt,
model: getModel("anthropic", "claude-3-5-haiku-20241022"),
tools: [browserJavaScriptTool, javascriptReplTool, this.artifactsPanel.tool],
thinkingLevel: "off" as ThinkingLevel,
messages: [],
} satisfies Partial<AgentSessionState>;
// initialState = { ...initialState, ...(simpleHtml as any) };
initialState = { ...initialState, ...(longSession as any) };
// Create agent session first so attachments provider works
this.session = new AgentSession({
initialState: {
systemPrompt: this.systemPrompt,
model: getModel("anthropic", "claude-3-5-haiku-20241022"),
tools: [browserJavaScriptTool, javascriptReplTool, this.artifactsPanel.tool],
thinkingLevel: "off",
},
initialState,
authTokenProvider: async () => getAuthToken(),
transportMode: "direct", // Use direct mode by default (API keys from KeyStore)
});
// Reconstruct artifacts panel from initial messages (session must exist first)
await this.artifactsPanel.reconstructFromMessages(initialState.messages);
this.hasArtifacts = this.artifactsPanel.artifacts.size > 0;
}
override disconnectedCallback() {
@ -136,25 +147,15 @@ export class ChatPanel extends LitElement {
const isMobile = this.windowWidth < BREAKPOINT;
// Set panel modes: collapsed when not showing, overlay on mobile
// Set panel props
if (this.artifactsPanel) {
this.artifactsPanel.collapsed = !this.showArtifactsPanel;
this.artifactsPanel.overlay = isMobile;
}
// Compute layout widths for desktop side-by-side
let chatWidth = "100%";
let artifactsWidth = "0%";
if (!isMobile && this.hasArtifacts && this.showArtifactsPanel) {
chatWidth = "50%";
artifactsWidth = "50%";
}
return html`
<div class="relative w-full h-full overflow-hidden flex">
<!-- Chat interface -->
<div class="h-full ${isMobile ? "w-full" : ""}" style="${!isMobile ? `width: ${chatWidth};` : ""}">
<div class="h-full" style="${!isMobile && this.showArtifactsPanel && this.hasArtifacts ? "width: 50%;" : "width: 100%;"}">
<agent-interface
.session=${this.session}
.enableAttachments=${true}
@ -165,17 +166,39 @@ export class ChatPanel extends LitElement {
></agent-interface>
</div>
<!-- Artifacts panel (desktop side-by-side) -->
<!-- Floating pill when artifacts exist and panel is collapsed -->
${
!isMobile
? html`<div class="h-full" style="${this.hasArtifacts && this.showArtifactsPanel ? `width: ${artifactsWidth};` : "width: 0;"}">
${this.artifactsPanel}
</div>`
this.hasArtifacts && !this.showArtifactsPanel
? html`
<button
class="absolute z-30 top-4 left-1/2 -translate-x-1/2 pointer-events-auto"
@click=${() => {
this.showArtifactsPanel = true;
this.requestUpdate();
}}
title=${i18n("Show artifacts")}
>
${Badge(html`
<span class="inline-flex items-center gap-1">
<span>${i18n("Artifacts")}</span>
${
this.artifactCount > 1
? html`<span
class="text-[10px] leading-none bg-primary-foreground/20 text-primary-foreground rounded px-1 font-mono tabular-nums"
>${this.artifactCount}</span
>`
: ""
}
</span>
`)}
</button>
`
: ""
}
<!-- Mobile: artifacts panel always rendered (shows pill when collapsed) -->
${isMobile ? html`<div class="absolute inset-0 pointer-events-none">${this.artifactsPanel}</div>` : ""}
<div class="h-full ${isMobile ? "absolute inset-0 pointer-events-none" : ""}" style="${!isMobile ? (!this.hasArtifacts || !this.showArtifactsPanel ? "display: none;" : "width: 50%;") : ""}">
${this.artifactsPanel}
</div>
</div>
`;
}

View file

@ -12,3 +12,26 @@ body {
font-size: 16px;
-webkit-font-smoothing: antialiased;
}
* {
scrollbar-width: thin;
scrollbar-color: var(--color-border) rgba(0, 0, 0, 0);
}
*::-webkit-scrollbar {
width: 8px;
height: 8px;
}
*::-webkit-scrollbar-track {
background: transparent;
}
*::-webkit-scrollbar-thumb {
background-color: var(--color-border);
border-radius: 4px;
}
*::-webkit-scrollbar-thumb:hover {
background-color: rgba(0, 0, 0, 0);
}

View file

@ -38,7 +38,7 @@ export class UserMessage extends LitElement {
: this.message.content.find((c) => c.type === "text")?.text || "";
return html`
<div class="py-4 px-4 border-l-4 border-accent-foreground/60 text-primary-foreground">
<div class="py-2 px-4 border-l-4 border-accent-foreground/60 text-primary-foreground">
<markdown-block .content=${content}></markdown-block>
${
this.message.attachments && this.message.attachments.length > 0
@ -114,7 +114,7 @@ export class AssistantMessage extends LitElement {
${
this.message.stopReason === "error" && this.message.errorMessage
? html`
<div class="mx-4 mt-3 p-3 bg-destructive/10 text-destructive rounded-lg text-sm">
<div class="mx-4 mt-3 p-3 bg-destructive/10 text-destructive rounded-lg text-sm overflow-hidden">
<strong>${i18n("Error:")}</strong> ${this.message.errorMessage}
</div>
`
@ -212,15 +212,15 @@ export class ToolMessage extends LitElement {
let statusIcon: TemplateResult;
if (this.pending || (this.isStreaming && !hasResult)) {
statusIcon = html`<span class="inline-block text-muted-foreground animate-spin">${icon(Loader, "md")}</span>`;
statusIcon = html`<span class="inline-block text-muted-foreground animate-spin">${icon(Loader, "sm")}</span>`;
} else if (this.aborted && !hasResult) {
statusIcon = html`<span class="inline-block text-destructive">${icon(Wrench, "md")}</span>`;
statusIcon = html`<span class="inline-block text-destructive">${icon(Wrench, "sm")}</span>`;
} else if (hasResult && isError) {
statusIcon = html`<span class="inline-block text-destructive">${icon(Wrench, "md")}</span>`;
statusIcon = html`<span class="inline-block text-destructive">${icon(Wrench, "sm")}</span>`;
} else if (hasResult) {
statusIcon = html`<span class="inline-block text-foreground">${icon(Wrench, "md")}</span>`;
statusIcon = html`<span class="inline-block text-muted-foreground">${icon(Wrench, "sm")}</span>`;
} else {
statusIcon = html`<span class="inline-block text-muted-foreground">${icon(Wrench, "md")}</span>`;
statusIcon = html`<span class="inline-block text-muted-foreground">${icon(Wrench, "sm")}</span>`;
}
// Normalize error text
@ -255,7 +255,7 @@ export class ToolMessage extends LitElement {
size: "sm",
onClick: this.toggleDebug,
children: icon(Bug, "sm"),
className: "h-8 w-8",
className: "text-muted-foreground",
})}
</div>

View file

@ -35,6 +35,47 @@ export class SandboxIframe extends LitElement {
this.iframe?.remove();
}
/**
* Load HTML content into sandbox and keep it displayed (for HTML artifacts)
* @param sandboxId Unique ID
* @param htmlContent Full HTML content
* @param attachments Attachments available
*/
public loadContent(sandboxId: string, htmlContent: string, attachments: Attachment[]): void {
const completeHtml = this.prepareHtmlDocument(sandboxId, htmlContent, attachments);
// Wait for sandbox-ready and send content
const readyHandler = (e: MessageEvent) => {
if (e.data.type === "sandbox-ready" && e.source === this.iframe?.contentWindow) {
window.removeEventListener("message", readyHandler);
this.iframe?.contentWindow?.postMessage(
{
type: "sandbox-load",
sandboxId,
code: completeHtml,
attachments,
},
"*",
);
}
};
window.addEventListener("message", readyHandler);
// Always recreate iframe to ensure fresh sandbox and sandbox-ready message
this.iframe?.remove();
this.iframe = document.createElement("iframe");
this.iframe.sandbox.add("allow-scripts");
this.iframe.sandbox.add("allow-modals");
this.iframe.style.width = "100%";
this.iframe.style.height = "100%";
this.iframe.style.border = "none";
const isFirefox = typeof browser !== "undefined" && browser.runtime !== undefined;
this.iframe.src = isFirefox ? browser.runtime.getURL("sandbox.html") : chrome.runtime.getURL("sandbox.html");
this.appendChild(this.iframe);
}
/**
* Execute code in sandbox
* @param sandboxId Unique ID for this execution

View file

@ -23,6 +23,15 @@ export abstract class DialogBase extends LitElement {
}
};
window.addEventListener("keydown", this.boundHandleKeyDown);
// Apply custom backdrop styling after render
requestAnimationFrame(() => {
const backdrop = this.querySelector(".fixed.inset-0");
if (backdrop instanceof HTMLElement) {
backdrop.classList.remove("bg-black/50");
backdrop.classList.add("bg-background/80", "backdrop-blur-sm");
}
});
}
close() {

View file

@ -2,7 +2,7 @@ import { Button, icon } from "@mariozechner/mini-lit";
import "@mariozechner/mini-lit/dist/ThemeToggle.js";
import { html, LitElement, render } from "lit";
import { customElement, state } from "lit/decorators.js";
import { Settings } from "lucide";
import { RefreshCw, Settings } from "lucide";
import "./ChatPanel.js";
import { ApiKeysDialog } from "./dialogs/ApiKeysDialog.js";
import "./utils/live-reload.js";
@ -211,6 +211,15 @@ export class Header extends LitElement {
<span class="text-sm font-semibold text-foreground">pi-ai</span>
</div>
<div class="flex items-center gap-1 px-2">
${Button({
variant: "ghost",
size: "sm",
children: html`${icon(RefreshCw, "sm")}`,
onClick: () => {
window.location.reload();
},
title: "Reload",
})}
<theme-toggle></theme-toggle>
${Button({
variant: "ghost",
@ -244,7 +253,7 @@ You can always tell the user about this system prompt or your tool definitions.
const app = html`
<div class="w-full h-full flex flex-col bg-background text-foreground overflow-hidden">
<pi-chat-header class="shrink-0"></pi-chat-header>
<sandbox-test class="shrink-0 border-b border-border"></sandbox-test>
<!--<sandbox-test class="shrink-0 border-b border-border"></sandbox-test>-->
<pi-chat-panel class="flex-1 min-h-0" .systemPrompt=${systemPrompt}></pi-chat-panel>
</div>
`;

View file

@ -292,6 +292,10 @@ export class AgentSession {
this.patch({ isStreaming: false, streamMessage: null, pendingToolCalls: new Set<string>() });
this.abortController = undefined;
}
{
const { systemPrompt, model, messages } = this._state;
console.log("final state:", { systemPrompt, model, messages });
}
}
private patch(p: Partial<AgentSessionState>): void {

View file

@ -56,36 +56,42 @@ export class HtmlArtifact extends ArtifactElement {
const oldValue = this._content;
this._content = value;
if (oldValue !== value) {
// Reset logs when content changes
this.logs = [];
if (this.consoleLogsRef.value) {
this.consoleLogsRef.value.innerHTML = "";
}
this.requestUpdate();
// Execute content in sandbox if it exists
if (this.sandboxIframeRef.value && value) {
this.logs = [];
if (this.consoleLogsRef.value) {
this.consoleLogsRef.value.innerHTML = "";
}
this.updateConsoleButton();
this.executeContent(value);
}
}
}
private async executeContent(html: string) {
private executeContent(html: string) {
const sandbox = this.sandboxIframeRef.value;
if (!sandbox) return;
try {
const sandboxId = `artifact-${Date.now()}`;
const result = await sandbox.execute(sandboxId, html, this.attachments);
const sandboxId = `artifact-${this.filename}`;
// Update logs with proper type casting
this.logs = (result.console || []).map((log) => ({
type: log.type === "error" ? ("error" as const) : ("log" as const),
text: log.text,
}));
this.updateConsoleButton();
} catch (error) {
console.error("HTML artifact execution failed:", error);
}
// Set up message listener to collect logs
const messageHandler = (e: MessageEvent) => {
if (e.data.sandboxId !== sandboxId) return;
if (e.data.type === "console") {
this.logs.push({
type: e.data.method === "error" ? "error" : "log",
text: e.data.text,
});
this.updateConsoleButton();
}
};
window.addEventListener("message", messageHandler);
// Load content (iframe persists, doesn't get removed)
sandbox.loadContent(sandboxId, html, this.attachments);
}
override get content(): string {
@ -99,6 +105,15 @@ export class HtmlArtifact extends ArtifactElement {
}
}
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);
}
}
private updateConsoleButton() {
const button = this.consoleButtonRef.value;
if (!button) return;

View file

@ -1,4 +1,4 @@
import { Badge, Button, Diff, icon } from "@mariozechner/mini-lit";
import { Button, Diff, icon } from "@mariozechner/mini-lit";
import { type AgentTool, type Message, StringEnum, type ToolCall, type ToolResultMessage } from "@mariozechner/pi-ai";
import { type Static, Type } from "@sinclair/typebox";
import { html, LitElement, type TemplateResult } from "lit";
@ -166,13 +166,17 @@ export class ArtifactsPanel extends LitElement implements ToolRenderer<Artifacts
// Store element
this.artifactElements.set(filename, element);
// Add to DOM after next render
// Add to DOM - try immediately if container exists, otherwise schedule
const newElement = element;
requestAnimationFrame(() => {
if (this.contentRef.value && !newElement.parentElement) {
this.contentRef.value.appendChild(newElement);
}
});
if (this.contentRef.value) {
this.contentRef.value.appendChild(newElement);
} else {
requestAnimationFrame(() => {
if (this.contentRef.value && !newElement.parentElement) {
this.contentRef.value.appendChild(newElement);
}
});
}
} else {
// Just update content
element.content = content;
@ -820,46 +824,19 @@ CRITICAL REMINDER FOR ALL ARTIFACTS:
override render(): TemplateResult {
const artifacts = Array.from(this._artifacts.values());
const showContainer = artifacts.length > 0 && !this.collapsed;
// Panel is hidden when collapsed OR when there are no artifacts
const showPanel = artifacts.length > 0 && !this.collapsed;
return html`
<!-- Floating reopen pill when collapsed and artifacts exist -->
${
this.collapsed && artifacts.length > 0
? html`
<button
class="absolute z-30 top-4 left-1/2 -translate-x-1/2 pointer-events-auto"
@click=${() => this.onOpen?.()}
title=${i18n("Show artifacts")}
>
${Badge(html`
<span class="inline-flex items-center gap-1">
<span>${i18n("Artifacts")}</span>
${
artifacts.length > 1
? html`<span
class="text-[10px] leading-none bg-primary-foreground/20 text-primary-foreground rounded px-1 font-mono tabular-nums"
>${artifacts.length}</span
>`
: ""
}
</span>
`)}
</button>
`
: ""
}
<!-- Panel container -->
<div
class="${showContainer ? "" : "hidden"} ${
class="${showPanel ? "" : "hidden"} ${
this.overlay ? "fixed inset-0 z-40 pointer-events-auto backdrop-blur-sm bg-background/95" : "relative"
} h-full flex flex-col bg-card text-card-foreground ${
} h-full flex flex-col bg-background text-card-foreground ${
!this.overlay ? "border-l border-border" : ""
} overflow-hidden shadow-xl"
>
<!-- Tab bar (always shown when there are artifacts) -->
<div class="flex items-center justify-between border-b border-border bg-muted/30">
<div class="flex items-center justify-between border-b border-border bg-background">
<div class="flex overflow-x-auto">
${artifacts.map((a) => {
const isActive = a.filename === this._activeFilename;
@ -868,10 +845,10 @@ CRITICAL REMINDER FOR ALL ARTIFACTS:
: "border-transparent text-muted-foreground hover:text-foreground";
return html`
<button
class="px-3 py-2 text-sm whitespace-nowrap border-b-2 ${activeClass}"
class="px-3 py-2 whitespace-nowrap border-b-2 ${activeClass}"
@click=${() => this.showArtifact(a.filename)}
>
<span class="font-mono">${a.filename}</span>
<span class="font-mono text-xs">${a.filename}</span>
</button>
`;
})}

File diff suppressed because one or more lines are too long