`;
}
/**
* Generate a unique ID for attachments
*/
private _generateAttachmentId(): string {
return `attachment-${Date.now()}-${
Math.random().toString(36).substring(2, 9)
}`;
}
/**
* Add an attachment
*/
addAttachment(attachment: PromptAttachment): void {
this._makeImagePreview(attachment);
this.attachments.set(attachment.id, attachment);
this.emit("cf-attachment-add", { attachment });
this.requestUpdate();
// Track the upload promise so a submit can await it (see _handleSend); it
// mutates the attachment + requestUpdate()s as its state changes, and
// re-emits cf-attachment-add when the url is ready.
const p = this._maybeUploadAttachment(attachment).finally(() => {
this._uploadPromises.delete(attachment.id);
});
this._uploadPromises.set(attachment.id, p);
}
/**
* Remove an attachment by ID
*/
removeAttachment(id: string): void {
const existing = this.attachments.get(id);
this._revokePreview(existing);
this.attachments.delete(id);
// Drop the tracked upload promise too, so a still-in-flight upload for an
// attachment the user already removed cannot delay the next send (_handleSend
// awaits _uploadPromises before emitting).
this._uploadPromises.delete(id);
this.emit("cf-attachment-remove", { id });
this.requestUpdate();
}
/**
* Give image attachments an instant local thumbnail (revoked on remove/send).
*/
private _makeImagePreview(attachment: PromptAttachment): void {
const data = attachment.data;
if (
(data instanceof File || data instanceof Blob) &&
(data.type || "").startsWith("image/")
) {
try {
attachment.previewUrl = URL.createObjectURL(data);
} catch {
// No object URL available (non-browser env) — fall back to the icon.
}
}
}
private _revokePreview(attachment: PromptAttachment | undefined): void {
if (attachment?.previewUrl) {
try {
URL.revokeObjectURL(attachment.previewUrl);
} catch {
// ignore
}
attachment.previewUrl = undefined;
}
}
/**
* Upload a File/Blob attachment to the blob store when opted in. We own this
* component and it's bound to our runtime, so it can upload directly rather
* than handing the consumer raw bytes it can't persist. Mutates the
* attachment in place (uploading/url/error) and requestUpdate()s.
*/
private async _maybeUploadAttachment(
attachment: PromptAttachment,
): Promise {
const data = attachment.data;
if (!this.uploadAttachments) return;
if (!(data instanceof File) && !(data instanceof Blob)) return;
// No runtime/space context — leave the raw data for the consumer.
if (!this.runtime || !this.space) return;
attachment.uploading = true;
this.requestUpdate();
try {
const file = data instanceof File
? data
: new File([data], attachment.name || "file", { type: data.type });
const stored = await uploadFile({
file,
runtime: this.runtime,
space: this.space,
});
attachment.url = stored.url;
attachment.mediaType = stored.mediaType;
attachment.size = stored.size;
attachment.error = undefined;
} catch (err) {
attachment.error = err instanceof Error ? err.message : String(err);
} finally {
attachment.uploading = false;
this.requestUpdate();
// Re-emit so consumers listening on cf-attachment-add receive the url.
this.emit("cf-attachment-add", { attachment });
}
}
/**
* Get icon for attachment type
*/
private _getAttachmentIcon(type: PromptAttachment["type"]): string {
switch (type) {
case "file":
return "📎";
case "clipboard":
return "📋";
default:
return "📄";
}
}
private _getAttachmentVariant(
type: PromptAttachment["type"],
): "default" | "primary" | "accent" {
switch (type) {
case "clipboard":
return "accent";
case "file":
default:
return "default";
}
}
/**
* Render pills list for attachments
*/
private _renderPillsList() {
if (this.attachments.size === 0) {
return "";
}
const attachmentsArray = Array.from(this.attachments.values());
return html`
${attachmentsArray.map((attachment) => {
// Prefer a local object URL for an instant preview, then the uploaded
// blob URL once it lands; only images get a thumbnail.
const dataType =
(attachment.data instanceof File || attachment.data instanceof Blob)
? attachment.data.type
: "";
const isImage = (attachment.mediaType || dataType || "")
.startsWith("image/");
const thumb = attachment.previewUrl ??
(isImage ? attachment.url : undefined);
return html`
this.removeAttachment(attachment.id)}"
>
${attachment.uploading
? html`
⏳
`
: ""}${attachment
.error
? html`
⚠️
`
: ""}${attachment.name} ${thumb
? html`
`
: html`
${this._getAttachmentIcon(
attachment.type,
)}
`}
`;
})}
`;
}
/**
* Handle voice transcription complete - append text to textarea
*/
private _handleTranscription(e: CustomEvent) {
const text = e.detail?.transcription?.text;
if (!text) return;
const textarea = this._textareaElement as HTMLTextAreaElement;
if (!textarea) return;
this.value = this.value + (this.value ? " " : "") + text;
textarea.value = this.value;
// Auto-resize and notify
if (this.autoResize) {
textarea.style.height = "auto";
textarea.style.height = `${
Math.min(
textarea.scrollHeight,
parseFloat(
getComputedStyle(this).getPropertyValue(
"--cf-prompt-input-max-height",
) || "12rem",
) * 16,
)
}px`;
}
this.emit("cf-input", { value: this.value });
textarea.focus();
}
/**
* Handle file upload button click
*/
private _handleUploadClick() {
const fileInput = this.shadowRoot?.querySelector(
'input[type="file"]',
) as HTMLInputElement;
fileInput?.click();
}
/**
* Handle file selection
*/
private _handleFileSelect(event: Event) {
const input = event.target as HTMLInputElement;
const files = input.files;
if (!files || files.length === 0) return;
for (const file of Array.from(files)) {
const id = this._generateAttachmentId();
const attachment: PromptAttachment = {
id,
name: file.name,
type: "file",
data: file,
};
this.addAttachment(attachment);
}
// Reset the input so the same file can be selected again
input.value = "";
}
/**
* Handle model selection change
*/
private _handleModelChange(event: Event) {
const select = event.target as HTMLSelectElement;
const newValue = select.value;
this._modelController.setValue(newValue);
// When model is a plain string (not bound to Cell), update it directly
if (typeof this.model === "string") {
this.model = newValue;
}
}
/**
* Apply the current model value to the DOM select element
* This ensures the select element shows the correct selected option
*/
private _applyModelValueToDom() {
// Re-query if we don't have a reference (e.g., model picker appeared after first render)
if (!this._modelSelectElement) {
this._modelSelectElement = this.shadowRoot?.querySelector(
".model-select",
) as HTMLSelectElement;
}
if (!this._modelSelectElement) return;
const currentValue = this._modelController.getValue();
if (currentValue != null) {
this._modelSelectElement.value = String(currentValue);
}
}
/**
* Mount the mentions overlay in the document body
*/
private _mountMentionsOverlay() {
if (this._mentionsOverlay) return;
const el = document.createElement("div");
el.style.position = "fixed";
el.style.inset = "0 auto auto 0";
el.style.zIndex = "1000";
el.style.pointerEvents = "auto";
el.dataset.cfPromptInputMentionsOverlay = "";
document.body.appendChild(el);
this._mentionsOverlay = el;
applyThemeToElement(el, this.theme ?? defaultTheme);
}
/**
* Unmount the mentions overlay from the document body
*/
private _unmountMentionsOverlay() {
if (this._mentionsOverlay) {
render(nothing, this._mentionsOverlay);
this._mentionsOverlay.remove();
this._mentionsOverlay = null;
}
if (this._raf) cancelAnimationFrame(this._raf);
this._raf = undefined;
}
/**
* Render the mentions dropdown into the overlay
*/
private _renderMentionsOverlay() {
if (!this._mentionsOverlay) return;
const filteredMentions = this.mentionController.getFilteredMentions();
if (filteredMentions.length === 0) {
this._unmountMentionsOverlay();
return;
}
// Inline styles so overlay has its own styling
const tpl = html`