web-ui package

This commit is contained in:
Mario Zechner 2025-10-05 13:30:08 +02:00
parent 7159c9734e
commit f2eecb78d2
55 changed files with 10932 additions and 13 deletions

View file

@ -0,0 +1,312 @@
import { html } from "@mariozechner/mini-lit";
import type { ToolResultMessage, Usage } from "@mariozechner/pi-ai";
import { LitElement } from "lit";
import { customElement, property, query } from "lit/decorators.js";
import { ApiKeysDialog } from "../dialogs/ApiKeysDialog.js";
import { ModelSelector } from "../dialogs/ModelSelector.js";
import type { MessageEditor } from "./MessageEditor.js";
import "./MessageEditor.js";
import "./MessageList.js";
import "./Messages.js"; // Import for side effects to register the custom elements
import type { AgentSession, AgentSessionEvent } from "../state/agent-session.js";
import { keyStore } from "../state/KeyStore.js";
import "./StreamingMessageContainer.js";
import type { Attachment } from "../utils/attachment-utils.js";
import { formatUsage } from "../utils/format.js";
import { i18n } from "../utils/i18n.js";
import type { StreamingMessageContainer } from "./StreamingMessageContainer.js";
@customElement("agent-interface")
export class AgentInterface extends LitElement {
// Optional external session: when provided, this component becomes a view over the session
@property({ attribute: false }) session?: AgentSession;
@property() enableAttachments = true;
@property() enableModelSelector = true;
@property() enableThinking = true;
@property() showThemeToggle = false;
@property() showDebugToggle = false;
// References
@query("message-editor") private _messageEditor!: MessageEditor;
@query("streaming-message-container") private _streamingContainer!: StreamingMessageContainer;
private _autoScroll = true;
private _lastScrollTop = 0;
private _lastClientHeight = 0;
private _scrollContainer?: HTMLElement;
private _resizeObserver?: ResizeObserver;
private _unsubscribeSession?: () => void;
public setInput(text: string, attachments?: Attachment[]) {
const update = () => {
if (!this._messageEditor) requestAnimationFrame(update);
else {
this._messageEditor.value = text;
this._messageEditor.attachments = attachments || [];
}
};
update();
}
protected override createRenderRoot(): HTMLElement | DocumentFragment {
return this;
}
override async connectedCallback() {
super.connectedCallback();
this.style.display = "flex";
this.style.flexDirection = "column";
this.style.height = "100%";
this.style.minHeight = "0";
// Wait for first render to get scroll container
await this.updateComplete;
this._scrollContainer = this.querySelector(".overflow-y-auto") as HTMLElement;
if (this._scrollContainer) {
// Set up ResizeObserver to detect content changes
this._resizeObserver = new ResizeObserver(() => {
if (this._autoScroll && this._scrollContainer) {
this._scrollContainer.scrollTop = this._scrollContainer.scrollHeight;
}
});
// Observe the content container inside the scroll container
const contentContainer = this._scrollContainer.querySelector(".max-w-3xl");
if (contentContainer) {
this._resizeObserver.observe(contentContainer);
}
// Set up scroll listener with better detection
this._scrollContainer.addEventListener("scroll", this._handleScroll);
}
// Subscribe to external session if provided
this.setupSessionSubscription();
// Attach debug listener if session provided
if (this.session) {
this.session = this.session; // explicitly set to trigger subscription
}
}
override disconnectedCallback() {
super.disconnectedCallback();
// Clean up observers and listeners
if (this._resizeObserver) {
this._resizeObserver.disconnect();
this._resizeObserver = undefined;
}
if (this._scrollContainer) {
this._scrollContainer.removeEventListener("scroll", this._handleScroll);
}
if (this._unsubscribeSession) {
this._unsubscribeSession();
this._unsubscribeSession = undefined;
}
}
private setupSessionSubscription() {
if (this._unsubscribeSession) {
this._unsubscribeSession();
this._unsubscribeSession = undefined;
}
if (!this.session) return;
this._unsubscribeSession = this.session.subscribe(async (ev: AgentSessionEvent) => {
if (ev.type === "state-update") {
if (this._streamingContainer) {
this._streamingContainer.isStreaming = ev.state.isStreaming;
this._streamingContainer.setMessage(ev.state.streamMessage, !ev.state.isStreaming);
}
this.requestUpdate();
} else if (ev.type === "error-no-model") {
// TODO show some UI feedback
} else if (ev.type === "error-no-api-key") {
// Open API keys dialog to configure the missing key
ApiKeysDialog.open();
}
});
}
private _handleScroll = (_ev: any) => {
if (!this._scrollContainer) return;
const currentScrollTop = this._scrollContainer.scrollTop;
const scrollHeight = this._scrollContainer.scrollHeight;
const clientHeight = this._scrollContainer.clientHeight;
const distanceFromBottom = scrollHeight - currentScrollTop - clientHeight;
// Ignore relayout due to message editor getting pushed up by stats
if (clientHeight < this._lastClientHeight) {
this._lastClientHeight = clientHeight;
return;
}
// Only disable auto-scroll if user scrolled UP or is far from bottom
if (currentScrollTop !== 0 && currentScrollTop < this._lastScrollTop && distanceFromBottom > 50) {
this._autoScroll = false;
} else if (distanceFromBottom < 10) {
// Re-enable if very close to bottom
this._autoScroll = true;
}
this._lastScrollTop = currentScrollTop;
this._lastClientHeight = clientHeight;
};
public async sendMessage(input: string, attachments?: Attachment[]) {
if ((!input.trim() && attachments?.length === 0) || this.session?.state.isStreaming) return;
const session = this.session;
if (!session) throw new Error("No session set on AgentInterface");
if (!session.state.model) throw new Error("No model set on AgentInterface");
// Check if API key exists for the provider (only needed in direct mode)
const provider = session.state.model.provider;
let apiKey = await keyStore.getKey(provider);
// If no API key, open the API keys dialog
if (!apiKey) {
await ApiKeysDialog.open();
// Check again after dialog closes
apiKey = await keyStore.getKey(provider);
// If still no API key, abort the send
if (!apiKey) {
return;
}
}
// Only clear editor after we know we can send
this._messageEditor.value = "";
this._messageEditor.attachments = [];
this._autoScroll = true; // Enable auto-scroll when sending a message
await this.session?.prompt(input, attachments);
}
private renderMessages() {
if (!this.session)
return html`<div class="p-4 text-center text-muted-foreground">${i18n("No session available")}</div>`;
const state = this.session.state;
// Build a map of tool results to allow inline rendering in assistant messages
const toolResultsById = new Map<string, ToolResultMessage<any>>();
for (const message of state.messages) {
if (message.role === "toolResult") {
toolResultsById.set(message.toolCallId, message);
}
}
return html`
<div class="flex flex-col gap-3">
<!-- Stable messages list - won't re-render during streaming -->
<message-list
.messages=${this.session.state.messages}
.tools=${state.tools}
.pendingToolCalls=${this.session ? this.session.state.pendingToolCalls : new Set<string>()}
.isStreaming=${state.isStreaming}
></message-list>
<!-- Streaming message container - manages its own updates -->
<streaming-message-container
class="${state.isStreaming ? "" : "hidden"}"
.tools=${state.tools}
.isStreaming=${state.isStreaming}
.pendingToolCalls=${state.pendingToolCalls}
.toolResultsById=${toolResultsById}
></streaming-message-container>
</div>
`;
}
private renderStats() {
if (!this.session) return html`<div class="text-xs h-5"></div>`;
const state = this.session.state;
const totals = state.messages
.filter((m) => m.role === "assistant")
.reduce(
(acc, msg: any) => {
const usage = msg.usage;
if (usage) {
acc.input += usage.input;
acc.output += usage.output;
acc.cacheRead += usage.cacheRead;
acc.cacheWrite += usage.cacheWrite;
acc.cost.total += usage.cost.total;
}
return acc;
},
{
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
} satisfies Usage,
);
const hasTotals = totals.input || totals.output || totals.cacheRead || totals.cacheWrite;
const totalsText = hasTotals ? formatUsage(totals) : "";
return html`
<div class="text-xs text-muted-foreground flex justify-between items-center h-5">
<div class="flex items-center gap-1">
${this.showThemeToggle ? html`<theme-toggle></theme-toggle>` : html``}
</div>
<div class="flex ml-auto items-center gap-3">${totalsText ? html`<span>${totalsText}</span>` : ""}</div>
</div>
`;
}
override render() {
if (!this.session)
return html`<div class="p-4 text-center text-muted-foreground">${i18n("No session set")}</div>`;
const session = this.session;
const state = this.session.state;
return html`
<div class="flex flex-col h-full bg-background text-foreground">
<!-- Messages Area -->
<div class="flex-1 overflow-y-auto">
<div class="max-w-3xl mx-auto p-4 pb-0">${this.renderMessages()}</div>
</div>
<!-- Input Area -->
<div class="shrink-0">
<div class="max-w-3xl mx-auto px-2">
<message-editor
.isStreaming=${state.isStreaming}
.currentModel=${state.model}
.thinkingLevel=${state.thinkingLevel}
.showAttachmentButton=${this.enableAttachments}
.showModelSelector=${this.enableModelSelector}
.showThinking=${this.enableThinking}
.onSend=${(input: string, attachments: Attachment[]) => {
this.sendMessage(input, attachments);
}}
.onAbort=${() => session.abort()}
.onModelSelect=${() => {
ModelSelector.open(state.model, (model) => session.setModel(model));
}}
.onThinkingChange=${
this.enableThinking
? (level: "off" | "minimal" | "low" | "medium" | "high") => {
session.setThinkingLevel(level);
}
: undefined
}
></message-editor>
${this.renderStats()}
</div>
</div>
</div>
`;
}
}
// Register custom element with guard
if (!customElements.get("agent-interface")) {
customElements.define("agent-interface", AgentInterface);
}

View file

@ -0,0 +1,112 @@
import { html, icon } from "@mariozechner/mini-lit";
import { LitElement } from "lit";
import { customElement, property } from "lit/decorators.js";
import { FileSpreadsheet, FileText, X } from "lucide";
import { AttachmentOverlay } from "../dialogs/AttachmentOverlay.js";
import type { Attachment } from "../utils/attachment-utils.js";
import { i18n } from "../utils/i18n.js";
@customElement("attachment-tile")
export class AttachmentTile extends LitElement {
@property({ type: Object }) attachment!: Attachment;
@property({ type: Boolean }) showDelete = false;
@property() onDelete?: () => void;
protected override createRenderRoot(): HTMLElement | DocumentFragment {
return this;
}
override connectedCallback(): void {
super.connectedCallback();
this.style.display = "block";
this.classList.add("max-h-16");
}
private handleClick = () => {
AttachmentOverlay.open(this.attachment);
};
override render() {
const hasPreview = !!this.attachment.preview;
const isImage = this.attachment.type === "image";
const isPdf = this.attachment.mimeType === "application/pdf";
const isDocx =
this.attachment.mimeType?.includes("wordprocessingml") ||
this.attachment.fileName.toLowerCase().endsWith(".docx");
const isPptx =
this.attachment.mimeType?.includes("presentationml") ||
this.attachment.fileName.toLowerCase().endsWith(".pptx");
const isExcel =
this.attachment.mimeType?.includes("spreadsheetml") ||
this.attachment.fileName.toLowerCase().endsWith(".xlsx") ||
this.attachment.fileName.toLowerCase().endsWith(".xls");
// Choose the appropriate icon
const getDocumentIcon = () => {
if (isExcel) return icon(FileSpreadsheet, "md");
return icon(FileText, "md");
};
return html`
<div class="relative group inline-block">
${
hasPreview
? html`
<div class="relative">
<img
src="data:${isImage ? this.attachment.mimeType : "image/png"};base64,${this.attachment.preview}"
class="w-16 h-16 object-cover rounded-lg border border-input cursor-pointer hover:opacity-80 transition-opacity"
alt="${this.attachment.fileName}"
title="${this.attachment.fileName}"
@click=${this.handleClick}
/>
${
isPdf
? html`
<!-- PDF badge overlay -->
<div class="absolute bottom-0 left-0 right-0 bg-background/90 px-1 py-0.5 rounded-b-lg">
<div class="text-[10px] text-muted-foreground text-center font-medium">${i18n("PDF")}</div>
</div>
`
: ""
}
</div>
`
: html`
<!-- Fallback: document icon + filename -->
<div
class="w-16 h-16 rounded-lg border border-input cursor-pointer hover:opacity-80 transition-opacity bg-muted text-muted-foreground flex flex-col items-center justify-center p-2"
@click=${this.handleClick}
title="${this.attachment.fileName}"
>
${getDocumentIcon()}
<div class="text-[10px] text-center truncate w-full">
${
this.attachment.fileName.length > 10
? this.attachment.fileName.substring(0, 8) + "..."
: this.attachment.fileName
}
</div>
</div>
`
}
${
this.showDelete
? html`
<button
@click=${(e: Event) => {
e.stopPropagation();
this.onDelete?.();
}}
class="absolute -top-1 -right-1 w-5 h-5 bg-background hover:bg-muted text-muted-foreground hover:text-foreground rounded-full flex items-center justify-center opacity-100 hover:opacity-100 [@media(hover:hover)]:opacity-0 [@media(hover:hover)]:group-hover:opacity-100 transition-opacity border border-input shadow-sm"
title="${i18n("Remove")}"
>
${icon(X, "xs")}
</button>
`
: ""
}
</div>
`;
}
}

View file

@ -0,0 +1,67 @@
import { html, icon } from "@mariozechner/mini-lit";
import { LitElement } from "lit";
import { property, state } from "lit/decorators.js";
import { Check, Copy } from "lucide";
import { i18n } from "../utils/i18n.js";
export class ConsoleBlock extends LitElement {
@property() content: string = "";
@state() private copied = false;
protected override createRenderRoot(): HTMLElement | DocumentFragment {
return this;
}
override connectedCallback(): void {
super.connectedCallback();
this.style.display = "block";
}
private async copy() {
try {
await navigator.clipboard.writeText(this.content || "");
this.copied = true;
setTimeout(() => {
this.copied = false;
}, 1500);
} catch (e) {
console.error("Copy failed", e);
}
}
override updated() {
// Auto-scroll to bottom on content changes
const container = this.querySelector(".console-scroll") as HTMLElement | null;
if (container) {
container.scrollTop = container.scrollHeight;
}
}
override render() {
return html`
<div class="border border-border rounded-lg overflow-hidden">
<div class="flex items-center justify-between px-3 py-1.5 bg-muted border-b border-border">
<span class="text-xs text-muted-foreground font-mono">${i18n("console")}</span>
<button
@click=${() => this.copy()}
class="flex items-center gap-1 px-2 py-0.5 text-xs rounded hover:bg-accent text-muted-foreground hover:text-accent-foreground transition-colors"
title="${i18n("Copy output")}"
>
${this.copied ? icon(Check, "sm") : icon(Copy, "sm")}
${this.copied ? html`<span>${i18n("Copied!")}</span>` : ""}
</button>
</div>
<div class="console-scroll overflow-auto max-h-64">
<pre class="!bg-background !border-0 !rounded-none m-0 p-3 text-xs text-foreground font-mono whitespace-pre-wrap">
${this.content || ""}</pre
>
</div>
</div>
`;
}
}
// Register custom element
if (!customElements.get("console-block")) {
customElements.define("console-block", ConsoleBlock);
}

View file

@ -0,0 +1,112 @@
import { type BaseComponentProps, fc, html } from "@mariozechner/mini-lit";
import { type Ref, ref } from "lit/directives/ref.js";
import { i18n } from "../utils/i18n.js";
export type InputType = "text" | "email" | "password" | "number" | "url" | "tel" | "search";
export type InputSize = "sm" | "md" | "lg";
export interface InputProps extends BaseComponentProps {
type?: InputType;
size?: InputSize;
value?: string;
placeholder?: string;
label?: string;
error?: string;
disabled?: boolean;
required?: boolean;
name?: string;
autocomplete?: string;
min?: number;
max?: number;
step?: number;
inputRef?: Ref<HTMLInputElement>;
onInput?: (e: Event) => void;
onChange?: (e: Event) => void;
onKeyDown?: (e: KeyboardEvent) => void;
onKeyUp?: (e: KeyboardEvent) => void;
}
export const Input = fc<InputProps>(
({
type = "text",
size = "md",
value = "",
placeholder = "",
label = "",
error = "",
disabled = false,
required = false,
name = "",
autocomplete = "",
min,
max,
step,
inputRef,
onInput,
onChange,
onKeyDown,
onKeyUp,
className = "",
}) => {
const sizeClasses = {
sm: "h-8 px-3 py-1 text-sm",
md: "h-9 px-3 py-1 text-sm md:text-sm",
lg: "h-10 px-4 py-1 text-base",
};
const baseClasses =
"flex w-full min-w-0 rounded-md border bg-transparent text-foreground shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium";
const interactionClasses =
"placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground";
const focusClasses = "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]";
const darkClasses = "dark:bg-input/30";
const stateClasses = error
? "border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40"
: "border-input";
const disabledClasses = "disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50";
const handleInput = (e: Event) => {
onInput?.(e);
};
const handleChange = (e: Event) => {
onChange?.(e);
};
return html`
<div class="flex flex-col gap-1.5 ${className}">
${
label
? html`
<label class="text-sm font-medium text-foreground">
${label} ${required ? html`<span class="text-destructive">${i18n("*")}</span>` : ""}
</label>
`
: ""
}
<input
type="${type}"
class="${baseClasses} ${
sizeClasses[size]
} ${interactionClasses} ${focusClasses} ${darkClasses} ${stateClasses} ${disabledClasses}"
.value=${value}
placeholder="${placeholder}"
?disabled=${disabled}
?required=${required}
?aria-invalid=${!!error}
name="${name}"
autocomplete="${autocomplete}"
min="${min ?? ""}"
max="${max ?? ""}"
step="${step ?? ""}"
@input=${handleInput}
@change=${handleChange}
@keydown=${onKeyDown}
@keyup=${onKeyUp}
${inputRef ? ref(inputRef) : ""}
/>
${error ? html`<span class="text-sm text-destructive">${error}</span>` : ""}
</div>
`;
},
);

View file

@ -0,0 +1,272 @@
import { Button, html, icon } from "@mariozechner/mini-lit";
import type { Model } from "@mariozechner/pi-ai";
import { LitElement } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { createRef, ref } from "lit/directives/ref.js";
import { Loader2, Paperclip, Send, Sparkles, Square } from "lucide";
import "./AttachmentTile.js";
import { type Attachment, loadAttachment } from "../utils/attachment-utils.js";
import { i18n } from "../utils/i18n.js";
@customElement("message-editor")
export class MessageEditor extends LitElement {
private _value = "";
private textareaRef = createRef<HTMLTextAreaElement>();
@property()
get value() {
return this._value;
}
set value(val: string) {
const oldValue = this._value;
this._value = val;
this.requestUpdate("value", oldValue);
this.updateComplete.then(() => {
const textarea = this.textareaRef.value;
if (textarea) {
textarea.style.height = "auto";
textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`;
}
});
}
@property() isStreaming = false;
@property() currentModel?: Model<any>;
@property() showAttachmentButton = true;
@property() showModelSelector = true;
@property() showThinking = false; // Disabled for now
@property() onInput?: (value: string) => void;
@property() onSend?: (input: string, attachments: Attachment[]) => void;
@property() onAbort?: () => void;
@property() onModelSelect?: () => void;
@property() onFilesChange?: (files: Attachment[]) => void;
@property() attachments: Attachment[] = [];
@property() maxFiles = 10;
@property() maxFileSize = 20 * 1024 * 1024; // 20MB
@property() acceptedTypes =
"image/*,application/pdf,.docx,.pptx,.xlsx,.xls,.txt,.md,.json,.xml,.html,.css,.js,.ts,.jsx,.tsx,.yml,.yaml";
@state() processingFiles = false;
private fileInputRef = createRef<HTMLInputElement>();
protected override createRenderRoot(): HTMLElement | DocumentFragment {
return this;
}
private handleTextareaInput = (e: Event) => {
const textarea = e.target as HTMLTextAreaElement;
this.value = textarea.value;
textarea.style.height = "auto";
textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`;
this.onInput?.(this.value);
};
private handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
if (!this.isStreaming && !this.processingFiles && (this.value.trim() || this.attachments.length > 0)) {
this.handleSend();
}
} else if (e.key === "Escape" && this.isStreaming) {
e.preventDefault();
this.onAbort?.();
}
};
private handleSend = () => {
this.onSend?.(this.value, this.attachments);
};
private handleAttachmentClick = () => {
this.fileInputRef.value?.click();
};
private async handleFilesSelected(e: Event) {
const input = e.target as HTMLInputElement;
const files = Array.from(input.files || []);
if (files.length === 0) return;
if (files.length + this.attachments.length > this.maxFiles) {
alert(`Maximum ${this.maxFiles} files allowed`);
input.value = "";
return;
}
this.processingFiles = true;
const newAttachments: Attachment[] = [];
for (const file of files) {
try {
if (file.size > this.maxFileSize) {
alert(`${file.name} exceeds maximum size of ${Math.round(this.maxFileSize / 1024 / 1024)}MB`);
continue;
}
const attachment = await loadAttachment(file);
newAttachments.push(attachment);
} catch (error) {
console.error(`Error processing ${file.name}:`, error);
alert(`Failed to process ${file.name}: ${String(error)}`);
}
}
this.attachments = [...this.attachments, ...newAttachments];
this.onFilesChange?.(this.attachments);
this.processingFiles = false;
input.value = ""; // Reset input
}
private removeFile(fileId: string) {
this.attachments = this.attachments.filter((f) => f.id !== fileId);
this.onFilesChange?.(this.attachments);
}
private adjustTextareaHeight() {
const textarea = this.textareaRef.value;
if (textarea) {
// Reset height to auto to get accurate scrollHeight
textarea.style.height = "auto";
// Only adjust if there's content, otherwise keep minimal height
if (this.value.trim()) {
textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`;
}
}
}
override firstUpdated() {
const textarea = this.textareaRef.value;
if (textarea) {
// Don't adjust height on first render - let it be minimal
textarea.focus();
}
}
override updated() {
// Only adjust height when component updates if there's content
if (this.value) {
this.adjustTextareaHeight();
}
}
override render() {
return html`
<div class="bg-card rounded-xl border border-border shadow-sm">
<!-- Attachments -->
${
this.attachments.length > 0
? html`
<div class="px-4 pt-3 pb-2 flex flex-wrap gap-2">
${this.attachments.map(
(attachment) => html`
<attachment-tile
.attachment=${attachment}
.showDelete=${true}
.onDelete=${() => this.removeFile(attachment.id)}
></attachment-tile>
`,
)}
</div>
`
: ""
}
<textarea
class="w-full bg-transparent p-4 text-foreground placeholder-muted-foreground outline-none resize-none overflow-y-auto"
placeholder=${i18n("Type a message...")}
rows="1"
style="max-height: 200px;"
.value=${this.value}
@input=${this.handleTextareaInput}
@keydown=${this.handleKeyDown}
${ref(this.textareaRef)}
></textarea>
<!-- Hidden file input -->
<input
type="file"
${ref(this.fileInputRef)}
@change=${this.handleFilesSelected}
accept=${this.acceptedTypes}
multiple
style="display: none;"
/>
<!-- Button Row -->
<div class="px-2 pb-2 flex items-center justify-between">
<!-- Left side - attachment and quick action buttons -->
<div class="flex gap-2 items-center">
${
this.showAttachmentButton
? this.processingFiles
? html`
<div class="h-8 w-8 flex items-center justify-center">
${icon(Loader2, "sm", "animate-spin text-muted-foreground")}
</div>
`
: html`
${Button({
variant: "ghost",
size: "icon",
className: "h-8 w-8",
onClick: this.handleAttachmentClick,
children: icon(Paperclip, "sm"),
})}
`
: ""
}
</div>
<!-- Model selector and send on the right -->
<div class="flex gap-2 items-center">
${
this.showModelSelector && this.currentModel
? html`
${Button({
variant: "ghost",
size: "sm",
onClick: () => {
// Focus textarea before opening model selector so focus returns there
this.textareaRef.value?.focus();
// Wait for next frame to ensure focus takes effect before dialog captures it
requestAnimationFrame(() => {
this.onModelSelect?.();
});
},
children: html`
${icon(Sparkles, "sm")}
<span class="ml-1">${this.currentModel.id}</span>
`,
className: "h-8 text-xs truncate",
})}
`
: ""
}
${
this.isStreaming
? html`
${Button({
variant: "ghost",
size: "icon",
onClick: this.onAbort,
children: icon(Square, "sm"),
className: "h-8 w-8",
})}
`
: html`
${Button({
variant: "ghost",
size: "icon",
onClick: this.handleSend,
disabled: (!this.value.trim() && this.attachments.length === 0) || this.processingFiles,
children: html`<div style="transform: rotate(-45deg)">${icon(Send, "sm")}</div>`,
className: "h-8 w-8",
})}
`
}
</div>
</div>
</div>
`;
}
}

View file

@ -0,0 +1,82 @@
import { html } from "@mariozechner/mini-lit";
import type {
AgentTool,
AssistantMessage as AssistantMessageType,
Message,
ToolResultMessage as ToolResultMessageType,
} from "@mariozechner/pi-ai";
import { LitElement, type TemplateResult } from "lit";
import { property } from "lit/decorators.js";
import { repeat } from "lit/directives/repeat.js";
export class MessageList extends LitElement {
@property({ type: Array }) messages: Message[] = [];
@property({ type: Array }) tools: AgentTool[] = [];
@property({ type: Object }) pendingToolCalls?: Set<string>;
@property({ type: Boolean }) isStreaming: boolean = false;
protected override createRenderRoot(): HTMLElement | DocumentFragment {
return this;
}
override connectedCallback(): void {
super.connectedCallback();
this.style.display = "block";
}
private buildRenderItems() {
// Map tool results by call id for quick lookup
const resultByCallId = new Map<string, ToolResultMessageType>();
for (const message of this.messages) {
if (message.role === "toolResult") {
resultByCallId.set(message.toolCallId, message);
}
}
const items: Array<{ key: string; template: TemplateResult }> = [];
let index = 0;
for (const msg of this.messages) {
if (msg.role === "user") {
items.push({
key: `msg:${index}`,
template: html`<user-message .message=${msg}></user-message>`,
});
index++;
} else if (msg.role === "assistant") {
const amsg = msg as AssistantMessageType;
items.push({
key: `msg:${index}`,
template: html`<assistant-message
.message=${amsg}
.tools=${this.tools}
.isStreaming=${this.isStreaming}
.pendingToolCalls=${this.pendingToolCalls}
.toolResultsById=${resultByCallId}
.hideToolCalls=${false}
></assistant-message>`,
});
index++;
} else {
// Skip standalone toolResult messages; they are rendered via paired tool-message above
// For completeness, other roles are not expected
}
}
return items;
}
override render() {
const items = this.buildRenderItems();
return html`<div class="flex flex-col gap-3">
${repeat(
items,
(it) => it.key,
(it) => it.template,
)}
</div>`;
}
}
// Register custom element
if (!customElements.get("message-list")) {
customElements.define("message-list", MessageList);
}

View file

@ -0,0 +1,310 @@
import { Button, html, icon } from "@mariozechner/mini-lit";
import type {
AgentTool,
AssistantMessage as AssistantMessageType,
ToolCall,
ToolResultMessage as ToolResultMessageType,
UserMessage as UserMessageType,
} from "@mariozechner/pi-ai";
import type { AgentToolResult } from "@mariozechner/pi-ai/dist/agent/types.js";
import { LitElement, type TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { Bug, Loader, Wrench } from "lucide";
import { renderToolParams, renderToolResult } from "../tools/index.js";
import type { Attachment } from "../utils/attachment-utils.js";
import { formatUsage } from "../utils/format.js";
import { i18n } from "../utils/i18n.js";
export type UserMessageWithAttachments = UserMessageType & { attachments?: Attachment[] };
export type AppMessage = AssistantMessageType | UserMessageWithAttachments | ToolResultMessageType;
@customElement("user-message")
export class UserMessage extends LitElement {
@property({ type: Object }) message!: UserMessageWithAttachments;
protected override createRenderRoot(): HTMLElement | DocumentFragment {
return this;
}
override connectedCallback(): void {
super.connectedCallback();
this.style.display = "block";
}
override render() {
const content =
typeof this.message.content === "string"
? this.message.content
: this.message.content.find((c) => c.type === "text")?.text || "";
return html`
<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
? html`
<div class="mt-3 flex flex-wrap gap-2">
${this.message.attachments.map(
(attachment) => html` <attachment-tile .attachment=${attachment}></attachment-tile> `,
)}
</div>
`
: ""
}
</div>
`;
}
}
@customElement("assistant-message")
export class AssistantMessage extends LitElement {
@property({ type: Object }) message!: AssistantMessageType;
@property({ type: Array }) tools?: AgentTool<any>[];
@property({ type: Object }) pendingToolCalls?: Set<string>;
@property({ type: Boolean }) hideToolCalls = false;
@property({ type: Object }) toolResultsById?: Map<string, ToolResultMessageType>;
@property({ type: Boolean }) isStreaming: boolean = false;
protected override createRenderRoot(): HTMLElement | DocumentFragment {
return this;
}
override connectedCallback(): void {
super.connectedCallback();
this.style.display = "block";
}
override render() {
// Render content in the order it appears
const orderedParts: TemplateResult[] = [];
for (const chunk of this.message.content) {
if (chunk.type === "text" && chunk.text.trim() !== "") {
orderedParts.push(html`<markdown-block .content=${chunk.text}></markdown-block>`);
} else if (chunk.type === "thinking" && chunk.thinking.trim() !== "") {
orderedParts.push(html` <markdown-block .content=${chunk.thinking} .isThinking=${true}></markdown-block> `);
} else if (chunk.type === "toolCall") {
if (!this.hideToolCalls) {
const tool = this.tools?.find((t) => t.name === chunk.name);
const pending = this.pendingToolCalls?.has(chunk.id) ?? false;
const result = this.toolResultsById?.get(chunk.id);
const aborted = !pending && !result && !this.isStreaming;
orderedParts.push(
html`<tool-message
.tool=${tool}
.toolCall=${chunk}
.result=${result}
.pending=${pending}
.aborted=${aborted}
.isStreaming=${this.isStreaming}
></tool-message>`,
);
}
}
}
return html`
<div>
${orderedParts.length ? html` <div class="px-4 flex flex-col gap-3">${orderedParts}</div> ` : ""}
${
this.message.usage
? html` <div class="px-4 mt-2 text-xs text-muted-foreground">${formatUsage(this.message.usage)}</div> `
: ""
}
${
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 overflow-hidden">
<strong>${i18n("Error:")}</strong> ${this.message.errorMessage}
</div>
`
: ""
}
${
this.message.stopReason === "aborted"
? html`<span class="text-sm text-destructive italic">${i18n("Request aborted")}</span>`
: ""
}
</div>
`;
}
}
@customElement("tool-message-debug")
export class ToolMessageDebugView extends LitElement {
@property({ type: Object }) callArgs: any;
@property({ type: String }) result?: AgentToolResult<any>;
@property({ type: Boolean }) hasResult: boolean = false;
protected override createRenderRoot(): HTMLElement | DocumentFragment {
return this; // light DOM for shared styles
}
override connectedCallback(): void {
super.connectedCallback();
this.style.display = "block";
}
private pretty(value: unknown): { content: string; isJson: boolean } {
try {
if (typeof value === "string") {
const maybeJson = JSON.parse(value);
return { content: JSON.stringify(maybeJson, null, 2), isJson: true };
}
return { content: JSON.stringify(value, null, 2), isJson: true };
} catch {
return { content: typeof value === "string" ? value : String(value), isJson: false };
}
}
override render() {
const output = this.pretty(this.result?.output);
const details = this.pretty(this.result?.details);
return html`
<div class="mt-3 flex flex-col gap-2">
<div>
<div class="text-xs font-medium mb-1 text-muted-foreground">${i18n("Call")}</div>
<code-block .code=${this.pretty(this.callArgs).content} language="json"></code-block>
</div>
<div>
<div class="text-xs font-medium mb-1 text-muted-foreground">${i18n("Result")}</div>
${
this.hasResult
? html`<code-block .code=${output.content} language="${output.isJson ? "json" : "text"}"></code-block>
<code-block .code=${details.content} language="${details.isJson ? "json" : "text"}"></code-block>`
: html`<div class="text-xs text-muted-foreground">${i18n("(no result)")}</div>`
}
</div>
</div>
`;
}
}
@customElement("tool-message")
export class ToolMessage extends LitElement {
@property({ type: Object }) toolCall!: ToolCall;
@property({ type: Object }) tool?: AgentTool<any>;
@property({ type: Object }) result?: ToolResultMessageType;
@property({ type: Boolean }) pending: boolean = false;
@property({ type: Boolean }) aborted: boolean = false;
@property({ type: Boolean }) isStreaming: boolean = false;
@state() private _showDebug = false;
protected override createRenderRoot(): HTMLElement | DocumentFragment {
return this;
}
override connectedCallback(): void {
super.connectedCallback();
this.style.display = "block";
}
private toggleDebug = () => {
this._showDebug = !this._showDebug;
};
override render() {
const toolLabel = this.tool?.label || this.toolCall.name;
const toolName = this.tool?.name || this.toolCall.name;
const isError = this.result?.isError === true;
const hasResult = !!this.result;
let statusIcon: TemplateResult;
if (this.pending || (this.isStreaming && !hasResult)) {
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, "sm")}</span>`;
} else if (hasResult && isError) {
statusIcon = html`<span class="inline-block text-destructive">${icon(Wrench, "sm")}</span>`;
} else if (hasResult) {
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, "sm")}</span>`;
}
// Normalize error text
let errorMessage = this.result?.output || "";
if (isError) {
try {
const parsed = JSON.parse(errorMessage);
if ((parsed as any).error) errorMessage = (parsed as any).error;
else if ((parsed as any).message) errorMessage = (parsed as any).message;
} catch {}
errorMessage = errorMessage.replace(/^(Tool )?Error:\s*/i, "");
errorMessage = errorMessage.replace(/^Error:\s*/i, "");
}
const paramsTpl = renderToolParams(
toolName,
this.toolCall.arguments,
this.isStreaming || (this.pending && !hasResult),
);
const resultTpl =
hasResult && !isError ? renderToolResult(toolName, this.toolCall.arguments, this.result!) : undefined;
return html`
<div class="p-2.5 border border-border rounded-md bg-card text-card-foreground">
<div class="flex items-center justify-between text-xs text-muted-foreground">
<div class="flex items-center gap-2">
${statusIcon}
<span class="font-medium">${toolLabel}</span>
</div>
${Button({
variant: this._showDebug ? "default" : "ghost",
size: "sm",
onClick: this.toggleDebug,
children: icon(Bug, "sm"),
className: "text-muted-foreground",
})}
</div>
${
this._showDebug
? html`<tool-message-debug
.callArgs=${this.toolCall.arguments}
.result=${this.result}
.hasResult=${!!this.result}
></tool-message-debug>`
: html`
<div class="mt-2 text-sm text-muted-foreground">${paramsTpl}</div>
${
this.pending && !hasResult
? html`<div class="mt-2 text-sm text-muted-foreground">${i18n("Waiting for tool result…")}</div>`
: ""
}
${
this.aborted && !hasResult
? html`<div class="mt-2 text-sm text-muted-foreground">${i18n("Call was aborted; no result.")}</div>`
: ""
}
${
hasResult && isError
? html`<div class="mt-2 p-2 border border-destructive rounded bg-destructive/10 text-sm text-destructive">
${errorMessage}
</div>`
: ""
}
${resultTpl ? html`<div class="mt-2">${resultTpl}</div>` : ""}
`
}
</div>
`;
}
}
@customElement("aborted-message")
export class AbortedMessage extends LitElement {
protected override createRenderRoot(): HTMLElement | DocumentFragment {
return this;
}
override connectedCallback(): void {
super.connectedCallback();
this.style.display = "block";
}
protected override render(): unknown {
return html`<span class="text-sm text-destructive italic">${i18n("Request aborted")}</span>`;
}
}

View file

@ -1,5 +1,5 @@
import { LitElement } from "lit";
import { customElement } from "lit/decorators.js";
import { customElement, property } from "lit/decorators.js";
import type { Attachment } from "../utils/attachment-utils.js";
export interface SandboxFile {
@ -15,10 +15,23 @@ export interface SandboxResult {
error?: { message: string; stack: string };
}
/**
* Function that returns the URL to the sandbox HTML file.
* Used in browser extensions to load sandbox.html via chrome.runtime.getURL().
*/
export type SandboxUrlProvider = () => string;
@customElement("sandbox-iframe")
export class SandboxIframe extends LitElement {
private iframe?: HTMLIFrameElement;
/**
* Optional: Provide a function that returns the sandbox HTML URL.
* If provided, the iframe will use this URL instead of srcdoc.
* This is required for browser extensions with strict CSP.
*/
@property({ attribute: false }) sandboxUrlProvider?: SandboxUrlProvider;
createRenderRoot() {
return this;
}
@ -41,6 +54,48 @@ export class SandboxIframe extends LitElement {
public loadContent(sandboxId: string, htmlContent: string, attachments: Attachment[]): void {
const completeHtml = this.prepareHtmlDocument(sandboxId, htmlContent, attachments);
if (this.sandboxUrlProvider) {
// Browser extension mode: use sandbox.html with postMessage
this.loadViaSandboxUrl(sandboxId, completeHtml, attachments);
} else {
// Web mode: use srcdoc
this.loadViaSrcdoc(completeHtml);
}
}
private loadViaSandboxUrl(sandboxId: string, completeHtml: string, attachments: Attachment[]): void {
// 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";
this.iframe.src = this.sandboxUrlProvider!();
this.appendChild(this.iframe);
}
private loadViaSrcdoc(completeHtml: string): void {
// Always recreate iframe to ensure fresh sandbox
this.iframe?.remove();
this.iframe = document.createElement("iframe");
@ -50,7 +105,7 @@ export class SandboxIframe extends LitElement {
this.iframe.style.height = "100%";
this.iframe.style.border = "none";
// Set content directly via srcdoc (no CSP restrictions in web-ui)
// Set content directly via srcdoc (no CSP restrictions in web apps)
this.iframe.srcdoc = completeHtml;
this.appendChild(this.iframe);
@ -125,9 +180,14 @@ export class SandboxIframe extends LitElement {
}
};
let readyHandler: ((e: MessageEvent) => void) | undefined;
const cleanup = () => {
window.removeEventListener("message", messageHandler);
signal?.removeEventListener("abort", abortHandler);
if (readyHandler) {
window.removeEventListener("message", readyHandler);
}
clearTimeout(timeoutId);
};
@ -148,19 +208,52 @@ export class SandboxIframe extends LitElement {
}
}, 30000);
// NOW create and append iframe AFTER all listeners are set up
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";
if (this.sandboxUrlProvider) {
// Browser extension mode: wait for sandbox-ready and send content
readyHandler = (e: MessageEvent) => {
if (e.data.type === "sandbox-ready" && e.source === this.iframe?.contentWindow) {
window.removeEventListener("message", readyHandler!);
// Send the complete HTML
this.iframe?.contentWindow?.postMessage(
{
type: "sandbox-load",
sandboxId,
code: completeHtml,
attachments,
},
"*",
);
}
};
window.addEventListener("message", readyHandler);
// Set content via srcdoc BEFORE appending to DOM (no CSP restrictions in web-ui)
this.iframe.srcdoc = completeHtml;
// Create iframe AFTER all listeners are set up
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";
this.appendChild(this.iframe);
this.iframe.src = this.sandboxUrlProvider();
this.appendChild(this.iframe);
} else {
// Web mode: use srcdoc
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";
// Set content via srcdoc BEFORE appending to DOM
this.iframe.srcdoc = completeHtml;
this.appendChild(this.iframe);
}
});
}

View file

@ -0,0 +1,101 @@
import { html } from "@mariozechner/mini-lit";
import type { AgentTool, Message, ToolResultMessage } from "@mariozechner/pi-ai";
import { LitElement } from "lit";
import { property, state } from "lit/decorators.js";
export class StreamingMessageContainer extends LitElement {
@property({ type: Array }) tools: AgentTool[] = [];
@property({ type: Boolean }) isStreaming = false;
@property({ type: Object }) pendingToolCalls?: Set<string>;
@property({ type: Object }) toolResultsById?: Map<string, ToolResultMessage>;
@state() private _message: Message | null = null;
private _pendingMessage: Message | null = null;
private _updateScheduled = false;
private _immediateUpdate = false;
protected override createRenderRoot(): HTMLElement | DocumentFragment {
return this;
}
override connectedCallback(): void {
super.connectedCallback();
this.style.display = "block";
}
// Public method to update the message with batching for performance
public setMessage(message: Message | null, immediate = false) {
// Store the latest message
this._pendingMessage = message;
// If this is an immediate update (like clearing), apply it right away
if (immediate || message === null) {
this._immediateUpdate = true;
this._message = message;
this.requestUpdate();
// Cancel any pending updates since we're clearing
this._pendingMessage = null;
this._updateScheduled = false;
return;
}
// Otherwise batch updates for performance during streaming
if (!this._updateScheduled) {
this._updateScheduled = true;
requestAnimationFrame(async () => {
// Only apply the update if we haven't been cleared
if (!this._immediateUpdate && this._pendingMessage !== null) {
// Deep clone the message to ensure Lit detects changes in nested properties
// (like toolCall.arguments being mutated during streaming)
this._message = JSON.parse(JSON.stringify(this._pendingMessage));
this.requestUpdate();
}
// Reset for next batch
this._pendingMessage = null;
this._updateScheduled = false;
this._immediateUpdate = false;
});
}
}
override render() {
// Show loading indicator if loading but no message yet
if (!this._message) {
if (this.isStreaming)
return html`<div class="flex flex-col gap-3 mb-3">
<span class="mx-4 inline-block w-2 h-4 bg-muted-foreground animate-pulse"></span>
</div>`;
return html``; // Empty until a message is set
}
const msg = this._message;
if (msg.role === "toolResult") {
// Skip standalone tool result in streaming; the stable list will render paired tool-message
return html``;
} else if (msg.role === "user") {
// Skip standalone tool result in streaming; the stable list will render it immediiately
return html``;
} else if (msg.role === "assistant") {
// Assistant message - render inline tool messages during streaming
return html`
<div class="flex flex-col gap-3 mb-3">
<assistant-message
.message=${msg}
.tools=${this.tools}
.isStreaming=${this.isStreaming}
.pendingToolCalls=${this.pendingToolCalls}
.toolResultsById=${this.toolResultsById}
.hideToolCalls=${false}
></assistant-message>
${this.isStreaming ? html`<span class="mx-4 inline-block w-2 h-4 bg-muted-foreground animate-pulse"></span>` : ""}
</div>
`;
}
}
}
// Register custom element
if (!customElements.get("streaming-message-container")) {
customElements.define("streaming-message-container", StreamingMessageContainer);
}