import { icon } from "@mariozechner/mini-lit"; import { Button } from "@mariozechner/mini-lit/dist/Button.js"; import { Select, type SelectOption, } from "@mariozechner/mini-lit/dist/Select.js"; import type { Model } from "@mariozechner/pi-ai"; import { html, LitElement } from "lit"; import { customElement, property, state } from "lit/decorators.js"; import { createRef, ref } from "lit/directives/ref.js"; import { Brain, Loader2, Paperclip, Send, Sparkles, Square } from "lucide"; import { type Attachment, loadAttachment } from "../utils/attachment-utils.js"; import { i18n } from "../utils/i18n.js"; import "./AttachmentTile.js"; import type { ThinkingLevel } from "@mariozechner/pi-agent-core"; @customElement("message-editor") export class MessageEditor extends LitElement { private _value = ""; private textareaRef = createRef(); @property() get value() { return this._value; } set value(val: string) { const oldValue = this._value; this._value = val; this.requestUpdate("value", oldValue); } @property() isStreaming = false; @property() currentModel?: Model; @property() thinkingLevel: ThinkingLevel = "off"; @property() showAttachmentButton = true; @property() showModelSelector = true; @property() showThinkingSelector = true; @property() onInput?: (value: string) => void; @property() onSend?: (input: string, attachments: Attachment[]) => void; @property() onAbort?: () => void; @property() onModelSelect?: () => void; @property() onThinkingChange?: ( level: "off" | "minimal" | "low" | "medium" | "high", ) => 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; @state() isDragging = false; private fileInputRef = createRef(); protected override createRenderRoot(): HTMLElement | DocumentFragment { return this; } private handleTextareaInput = (e: Event) => { const textarea = e.target as HTMLTextAreaElement; this.value = textarea.value; 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 handlePaste = async (e: ClipboardEvent) => { const items = e.clipboardData?.items; if (!items) return; const imageFiles: File[] = []; // Check for image items in clipboard for (let i = 0; i < items.length; i++) { const item = items[i]; if (item.type.startsWith("image/")) { const file = item.getAsFile(); if (file) { imageFiles.push(file); } } } // If we found images, process them if (imageFiles.length > 0) { e.preventDefault(); // Prevent default paste behavior if (imageFiles.length + this.attachments.length > this.maxFiles) { alert(`Maximum ${this.maxFiles} files allowed`); return; } this.processingFiles = true; const newAttachments: Attachment[] = []; for (const file of imageFiles) { try { if (file.size > this.maxFileSize) { alert( `Image 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 pasted image:", error); alert(`Failed to process pasted image: ${String(error)}`); } } this.attachments = [...this.attachments, ...newAttachments]; this.onFilesChange?.(this.attachments); this.processingFiles = false; } }; 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 handleDragOver = (e: DragEvent) => { e.preventDefault(); e.stopPropagation(); if (!this.isDragging) { this.isDragging = true; } }; private handleDragLeave = (e: DragEvent) => { e.preventDefault(); e.stopPropagation(); // Only set isDragging to false if we're leaving the entire component const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); const x = e.clientX; const y = e.clientY; if ( x <= rect.left || x >= rect.right || y <= rect.top || y >= rect.bottom ) { this.isDragging = false; } }; private handleDrop = async (e: DragEvent) => { e.preventDefault(); e.stopPropagation(); this.isDragging = false; const files = Array.from(e.dataTransfer?.files || []); if (files.length === 0) return; if (files.length + this.attachments.length > this.maxFiles) { alert(`Maximum ${this.maxFiles} files allowed`); 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; }; override firstUpdated() { const textarea = this.textareaRef.value; if (textarea) { textarea.focus(); } } override render() { // Check if current model supports thinking/reasoning const model = this.currentModel; const supportsThinking = model?.reasoning === true; // Models with reasoning:true support thinking return html`
${this.isDragging ? html`
${i18n("Drop files here")}
` : ""} ${this.attachments.length > 0 ? html`
${this.attachments.map( (attachment) => html` this.removeFile(attachment.id)} > `, )}
` : ""}
${this.showAttachmentButton ? this.processingFiles ? html`
${icon( Loader2, "sm", "animate-spin text-muted-foreground", )}
` : html` ${Button({ variant: "ghost", size: "icon", className: "h-8 w-8", onClick: this.handleAttachmentClick, children: icon(Paperclip, "sm"), })} ` : ""} ${supportsThinking && this.showThinkingSelector ? html` ${Select({ value: this.thinkingLevel, placeholder: i18n("Off"), options: [ { value: "off", label: i18n("Off"), icon: icon(Brain, "sm"), }, { value: "minimal", label: i18n("Minimal"), icon: icon(Brain, "sm"), }, { value: "low", label: i18n("Low"), icon: icon(Brain, "sm"), }, { value: "medium", label: i18n("Medium"), icon: icon(Brain, "sm"), }, { value: "high", label: i18n("High"), icon: icon(Brain, "sm"), }, ] as SelectOption[], onChange: (value: string) => { this.onThinkingChange?.( value as "off" | "minimal" | "low" | "medium" | "high", ); }, width: "80px", size: "sm", variant: "ghost", fitContent: true, })} ` : ""}
${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")} ${this.currentModel.id} `, 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`
${icon(Send, "sm")}
`, className: "h-8 w-8", })} `}
`; } }