feat: support general chat file attachments

This commit is contained in:
Federico Jaramillo Martinez
2026-06-25 21:32:07 +02:00
parent 790b36f0ea
commit 7e812aa7f5
11 changed files with 397 additions and 96 deletions
+59 -32
View File
@@ -7,7 +7,7 @@ import { LitElement, html, type PropertyValues } from "lit";
import { customElement, property, query, state } from "lit/decorators.js";
import { api, type FileSuggestion, type PromptAttachment, type SessionStatus, type SlashCommand } from "../api";
import type { PromptAttachmentDelivery } from "../../../shared/apiTypes";
import { captureImageAttachments } from "../promptAttachmentCapture";
import { capturePromptAttachments, effectivePromptAttachmentDelivery, isInlinePromptAttachment, promptAttachmentsCanUseInlineDelivery, type CapturedAttachment } from "../promptAttachmentCapture";
import { inputModeForDraft } from "../inputModes";
import { machineSessionKey } from "../machineKeys";
import { detectPromptCompletionTrigger, fileCompletionInsertText, type PromptCompletionTrigger } from "../promptCompletions";
@@ -18,14 +18,7 @@ import { renderAttachIcon, renderSendIcon, renderQueueIcon, renderSteerIcon, ren
import { thinkingGauge, thinkingLevelLabel } from "../../../shared/thinkingLevels";
import "./AutocompleteMenu";
interface PendingAttachment {
id: string;
name: string;
mimeType: string;
/** Base64 payload without the data: URL prefix. */
data: string;
size: number;
}
type PendingAttachment = CapturedAttachment & { id: string };
@customElement("prompt-editor")
export class PromptEditor extends LitElement {
@@ -96,8 +89,8 @@ export class PromptEditor extends LitElement {
<footer class=${shellMode ? "shell-mode" : ""} @paste=${(event: ClipboardEvent) => { void this.handlePaste(event); }} @dragover=${(event: DragEvent) => { this.handleDragOver(event); }} @drop=${(event: DragEvent) => { void this.handleDrop(event); }}>
<div class="editor-wrap">
<div class=${`markdown-editor${this.disabled ? " markdown-editor-disabled" : ""}`} aria-label="Message pi" aria-disabled=${this.disabled ? "true" : "false"}></div>
<input class="attachment-input" type="file" accept="image/png,image/jpeg,image/gif,image/webp" multiple hidden @change=${(event: Event) => { void this.handleFileInput(event); }} />
<button class="editor-attach icon-button" ?disabled=${busy} title="Attach images" aria-label="Attach images" @click=${() => { this.attachmentInput?.click(); }}>${renderAttachIcon()}</button>
<input class="attachment-input" type="file" multiple hidden @change=${(event: Event) => { void this.handleFileInput(event); }} />
<button class="editor-attach icon-button" ?disabled=${busy} title="Attach files" aria-label="Attach files" @click=${() => { this.attachmentInput?.click(); }}>${renderAttachIcon()}</button>
${shellMode ? html`<div class="mode-hint">Shell command${inputMode.excludeFromContext ? " · excluded from context" : ""}</div>` : null}
${this.isCompacting && !shellMode ? html`<div class="mode-hint">Compacting history · message will be queued</div>` : null}
${this.renderAttachments()}
@@ -137,18 +130,20 @@ export class PromptEditor extends LitElement {
private renderAttachments() {
if (this.attachments.length === 0 && this.attachmentError === undefined) return null;
const canUseInlineDelivery = promptAttachmentsCanUseInlineDelivery(this.attachments);
const delivery = this.effectiveAttachmentDelivery();
return html`
<div class="attachments" aria-label="Pending attachments">
${this.attachments.map((attachment) => html`
<div class="attachment-chip" title=${attachment.name}>
<img src=${`data:${attachment.mimeType};base64,${attachment.data}`} alt=${attachment.name} />
<div class=${`attachment-chip ${isInlinePromptAttachment(attachment) ? "attachment-chip-image" : "attachment-chip-file"}`} title=${attachment.name}>
${this.renderAttachmentPreview(attachment)}
<button type="button" class="attachment-remove" title="Remove attachment" aria-label=${`Remove ${attachment.name}`} @click=${() => { this.removeAttachment(attachment.id); }}>×</button>
</div>
`)}
${this.attachments.length > 0 ? html`
<label class="attachment-delivery" title="How attachments are delivered to the agent">
<select .value=${this.attachmentDelivery} @change=${(event: Event) => { this.changeDelivery(event); }}>
<option value="inline">Attach to message</option>
<label class="attachment-delivery" title=${canUseInlineDelivery ? "How attachments are delivered to the agent" : "General files are saved and mentioned from the workspace"}>
<select .value=${delivery} @change=${(event: Event) => { this.changeDelivery(event); }}>
<option value="inline" ?disabled=${!canUseInlineDelivery}>Attach to message${canUseInlineDelivery ? "" : " (images only)"}</option>
<option value="folder">Save to .pi-web/attachments</option>
</select>
</label>
@@ -158,9 +153,24 @@ export class PromptEditor extends LitElement {
`;
}
private renderAttachmentPreview(attachment: PendingAttachment) {
if (isInlinePromptAttachment(attachment)) {
return html`<img src=${`data:${attachment.mimeType};base64,${attachment.data}`} alt=${attachment.name} />`;
}
return html`
<div class="attachment-file-preview" aria-hidden="true">${fileExtensionLabel(attachment.name)}</div>
<span class="attachment-file-name">${attachment.name}</span>
`;
}
private changeDelivery(event: Event) {
if (!(event.target instanceof HTMLSelectElement)) return;
this.attachmentDelivery = event.target.value === "folder" ? "folder" : "inline";
const requested = event.target.value === "folder" ? "folder" : "inline";
if (requested === "inline" && !promptAttachmentsCanUseInlineDelivery(this.attachments)) {
event.target.value = "folder";
return;
}
this.attachmentDelivery = requested;
saveAttachmentDelivery(this.attachmentDelivery);
}
@@ -169,7 +179,7 @@ export class PromptEditor extends LitElement {
}
private async handlePaste(event: ClipboardEvent) {
const files = imageFilesFromDataTransfer(event.clipboardData);
const files = filesFromDataTransfer(event.clipboardData);
if (files.length === 0) return;
event.preventDefault();
await this.addAttachmentFiles(files);
@@ -177,13 +187,11 @@ export class PromptEditor extends LitElement {
private handleDragOver(event: DragEvent) {
if (event.dataTransfer === null) return;
if (Array.from(event.dataTransfer.items).some((item) => item.kind === "file" && item.type.startsWith("image/"))) {
event.preventDefault();
}
if (dataTransferHasFiles(event.dataTransfer)) event.preventDefault();
}
private async handleDrop(event: DragEvent) {
const files = imageFilesFromDataTransfer(event.dataTransfer);
const files = filesFromDataTransfer(event.dataTransfer);
if (files.length === 0) return;
event.preventDefault();
await this.addAttachmentFiles(files);
@@ -198,7 +206,7 @@ export class PromptEditor extends LitElement {
private async addAttachmentFiles(files: File[]) {
this.attachmentError = undefined;
const { attachments, error } = await captureImageAttachments(files, readFileAsBase64);
const { attachments, error } = await capturePromptAttachments(files, readFileAsBase64);
if (attachments.length > 0) {
this.attachments = [...this.attachments, ...attachments.map((attachment) => ({ id: `attachment-${String(++this.attachmentSeq)}`, ...attachment }))];
}
@@ -206,12 +214,11 @@ export class PromptEditor extends LitElement {
}
private currentAttachments(): PromptAttachment[] {
return this.attachments.map((attachment) => ({
kind: "image",
mimeType: attachment.mimeType,
data: attachment.data,
name: attachment.name,
}));
return this.attachments.map((attachment) => pendingToPromptAttachment(attachment));
}
private effectiveAttachmentDelivery(): PromptAttachmentDelivery {
return effectivePromptAttachmentDelivery(this.attachmentDelivery, this.attachments);
}
private createEditor() {
@@ -380,7 +387,7 @@ export class PromptEditor extends LitElement {
if (text === "" && pending.length === 0) return;
const behavior = this.canSteer || this.isCompacting ? streamingBehavior : undefined;
const attachments = pending.length > 0 ? this.currentAttachments() : undefined;
const delivery = this.attachmentDelivery;
const delivery = this.effectiveAttachmentDelivery();
this.resetComposer();
// Sending is owned by the controller (it drives the chat activity dock and,
// for folder mode, orchestrates the upload + reference rewrite), so this is
@@ -414,9 +421,29 @@ function emptyFileSuggestions(): FileSuggestion[] {
return [];
}
function imageFilesFromDataTransfer(data: DataTransfer | null): File[] {
function filesFromDataTransfer(data: DataTransfer | null): File[] {
if (data === null) return [];
return Array.from(data.files).filter((file) => file.type.startsWith("image/"));
return Array.from(data.files);
}
function dataTransferHasFiles(data: DataTransfer): boolean {
const items = Array.from(data.items);
if (items.length > 0) return items.some((item) => item.kind === "file");
return Array.from(data.types).includes("Files");
}
function pendingToPromptAttachment(attachment: PendingAttachment): PromptAttachment {
if (attachment.kind === "image") {
return { kind: "image", mimeType: attachment.mimeType, data: attachment.data, name: attachment.name };
}
return { kind: "file", mimeType: attachment.mimeType, data: attachment.data, name: attachment.name };
}
function fileExtensionLabel(name: string): string {
const trimmed = name.trim();
const dotIndex = trimmed.lastIndexOf(".");
if (dotIndex >= 0 && dotIndex < trimmed.length - 1) return trimmed.slice(dotIndex + 1, dotIndex + 5).toUpperCase();
return "FILE";
}
function readFileAsBase64(file: File): Promise<string> {
+3
View File
@@ -475,6 +475,9 @@ export const promptEditorStyles = css`
.attachments { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 8px; }
.attachment-chip { position: relative; width: 56px; height: 56px; border: 1px solid var(--pi-border); border-radius: 8px; overflow: hidden; background: var(--pi-bg); }
.attachment-chip img { width: 100%; height: 100%; object-fit: cover; display: block; }
.attachment-chip-file { display: grid; place-items: center; }
.attachment-file-preview { display: grid; place-items: center; width: 34px; height: 26px; border: 1px solid var(--pi-border-muted); border-radius: 4px; background: var(--pi-surface); color: var(--pi-muted); font: 700 10px/1 system-ui, sans-serif; letter-spacing: .03em; }
.attachment-file-name { position: absolute; right: 4px; bottom: 3px; left: 4px; overflow: hidden; color: var(--pi-muted); font-size: 10px; line-height: 1.2; text-align: center; text-overflow: ellipsis; white-space: nowrap; }
.attachment-remove { position: absolute; top: 1px; right: 1px; width: 18px; height: 18px; padding: 0; line-height: 16px; border-radius: 50%; border: 1px solid var(--pi-border); background: var(--pi-surface); color: var(--pi-text); font-size: 13px; cursor: pointer; }
.attachment-delivery select { border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); color: var(--pi-text); padding: 5px 7px; font: 12px system-ui, sans-serif; }
.attachment-error { flex-basis: 100%; color: var(--pi-danger); font-size: 12px; }
+48 -18
View File
@@ -1,51 +1,81 @@
import { describe, expect, it } from "vitest";
import { captureImageAttachments, READ_FAILURE_MESSAGE, UNSUPPORTED_IMAGE_MESSAGE, type CapturableFile } from "./promptAttachmentCapture";
import { capturePromptAttachments, DEFAULT_FILE_MIME_TYPE, effectivePromptAttachmentDelivery, READ_FAILURE_MESSAGE, type CapturableFile } from "./promptAttachmentCapture";
function file(name: string, type: string, size = 10): CapturableFile {
return { name, type, size };
}
describe("captureImageAttachments", () => {
it("reads supported images as base64 attachments", async () => {
const result = await captureImageAttachments(
describe("capturePromptAttachments", () => {
it("reads supported images as native inline image attachments", async () => {
const result = await capturePromptAttachments(
[file("shot.png", "image/png"), file("pic.webp", "image/webp")],
(f) => Promise.resolve(`data-for-${f.name}`),
);
expect(result.error).toBeUndefined();
expect(result.attachments).toEqual([
{ name: "shot.png", mimeType: "image/png", data: "data-for-shot.png", size: 10 },
{ name: "pic.webp", mimeType: "image/webp", data: "data-for-pic.webp", size: 10 },
{ kind: "image", name: "shot.png", mimeType: "image/png", data: "data-for-shot.png", size: 10 },
{ kind: "image", name: "pic.webp", mimeType: "image/webp", data: "data-for-pic.webp", size: 10 },
]);
});
it("derives a name from the mime type when the file is unnamed", async () => {
const result = await captureImageAttachments([file("", "image/jpeg")], () => Promise.resolve("x"));
expect(result.attachments[0]?.name).toBe("pasted-image.jpg");
it("captures generic files with their browser MIME type", async () => {
const result = await capturePromptAttachments(
[file("report.pdf", "application/pdf", 1234), file("vector.svg", "image/svg+xml")],
(f) => Promise.resolve(`data-for-${f.name}`),
);
expect(result.error).toBeUndefined();
expect(result.attachments).toEqual([
{ kind: "file", name: "report.pdf", mimeType: "application/pdf", data: "data-for-report.pdf", size: 1234 },
{ kind: "file", name: "vector.svg", mimeType: "image/svg+xml", data: "data-for-vector.svg", size: 10 },
]);
});
it("skips unsupported types and reports a single error while keeping valid ones", async () => {
const result = await captureImageAttachments(
[file("doc.pdf", "application/pdf"), file("ok.gif", "image/gif")],
it("uses application/octet-stream when the browser does not provide a MIME type", async () => {
const result = await capturePromptAttachments([file("archive", "")], () => Promise.resolve("x"));
expect(result.attachments[0]).toMatchObject({ kind: "file", name: "archive", mimeType: DEFAULT_FILE_MIME_TYPE });
});
it("derives fallback names for unnamed pasted attachments", async () => {
const result = await capturePromptAttachments(
[file("", "image/jpeg"), file("", "application/pdf")],
() => Promise.resolve("x"),
);
expect(result.error).toBe(UNSUPPORTED_IMAGE_MESSAGE);
expect(result.attachments.map((attachment) => attachment.name)).toEqual(["ok.gif"]);
expect(result.attachments.map((attachment) => attachment.name)).toEqual(["pasted-image.jpg", "pasted-file.bin"]);
});
it("reports a read failure without dropping other attachments", async () => {
const result = await captureImageAttachments(
[file("bad.png", "image/png"), file("good.png", "image/png")],
const result = await capturePromptAttachments(
[file("bad.png", "image/png"), file("good.txt", "text/plain")],
(f) => f.name === "bad.png" ? Promise.reject(new Error("boom")) : Promise.resolve("ok"),
);
expect(result.error).toBe(READ_FAILURE_MESSAGE);
expect(result.attachments.map((attachment) => attachment.name)).toEqual(["good.png"]);
expect(result.attachments.map((attachment) => attachment.name)).toEqual(["good.txt"]);
});
it("returns no attachments and no error for an empty batch", async () => {
const result = await captureImageAttachments([], () => Promise.resolve("x"));
const result = await capturePromptAttachments([], () => Promise.resolve("x"));
expect(result).toEqual({ attachments: [] });
});
});
describe("effectivePromptAttachmentDelivery", () => {
it("preserves inline delivery when all pending attachments are supported images", () => {
expect(effectivePromptAttachmentDelivery("inline", [{ kind: "image", mimeType: "image/png" }])).toBe("inline");
});
it("preserves an explicit folder preference for supported images", () => {
expect(effectivePromptAttachmentDelivery("folder", [{ kind: "image", mimeType: "image/png" }])).toBe("folder");
});
it("forces folder delivery when any attachment is a generic file", () => {
expect(effectivePromptAttachmentDelivery("inline", [
{ kind: "image", mimeType: "image/png" },
{ kind: "file", mimeType: "application/pdf" },
])).toBe("folder");
});
});
+55 -18
View File
@@ -1,3 +1,4 @@
import type { PromptAttachmentDelivery } from "../../shared/apiTypes";
import { extensionForImageMimeType, isSupportedImageMimeType } from "../../shared/promptAttachments";
/**
@@ -11,44 +12,51 @@ export interface CapturableFile {
size: number;
}
export interface CapturedAttachment {
name: string;
mimeType: string;
/** Base64 payload without the data: URL prefix. */
data: string;
size: number;
}
export type CapturedAttachment =
| {
kind: "image";
name: string;
mimeType: string;
/** Base64 payload without the data: URL prefix. */
data: string;
size: number;
}
| {
kind: "file";
name: string;
mimeType: string;
/** Base64 payload without the data: URL prefix. */
data: string;
size: number;
};
export interface CaptureResult {
attachments: CapturedAttachment[];
error?: string;
}
export const UNSUPPORTED_IMAGE_MESSAGE = "Only PNG, JPEG, GIF, and WebP images are supported.";
export const DEFAULT_FILE_MIME_TYPE = "application/octet-stream";
export const READ_FAILURE_MESSAGE = "Failed to read an attachment.";
/**
* Validate a batch of files and read the supported images as base64.
* Read a batch of browser files as prompt attachments.
*
* Pure orchestration: the actual byte reading is injected so the side effect
* (FileReader/Blob access) stays at the component boundary and tests can supply
* a fake reader. Unsupported types and read failures are collected into a single
* user-facing error while still returning every attachment that did succeed.
* a fake reader. Supported image MIME types stay marked as native inline images;
* every other file is captured as a generic file attachment that must be saved
* into the workspace before being mentioned in the prompt.
*/
export async function captureImageAttachments<T extends CapturableFile>(
export async function capturePromptAttachments<T extends CapturableFile>(
files: readonly T[],
readBase64: (file: T) => Promise<string>,
): Promise<CaptureResult> {
const attachments: CapturedAttachment[] = [];
let error: string | undefined;
for (const file of files) {
if (!isSupportedImageMimeType(file.type)) {
error = UNSUPPORTED_IMAGE_MESSAGE;
continue;
}
try {
const data = await readBase64(file);
attachments.push({ name: attachmentName(file), mimeType: file.type, data, size: file.size });
attachments.push(capturedAttachment(file, data));
} catch {
error = READ_FAILURE_MESSAGE;
}
@@ -56,6 +64,35 @@ export async function captureImageAttachments<T extends CapturableFile>(
return { attachments, ...(error === undefined ? {} : { error }) };
}
export function isInlinePromptAttachment(attachment: Pick<CapturedAttachment, "kind" | "mimeType">): boolean {
return attachment.kind === "image" && isSupportedImageMimeType(attachment.mimeType);
}
export function promptAttachmentsCanUseInlineDelivery(attachments: readonly Pick<CapturedAttachment, "kind" | "mimeType">[]): boolean {
return attachments.every((attachment) => isInlinePromptAttachment(attachment));
}
export function effectivePromptAttachmentDelivery(
preferredDelivery: PromptAttachmentDelivery,
attachments: readonly Pick<CapturedAttachment, "kind" | "mimeType">[],
): PromptAttachmentDelivery {
return promptAttachmentsCanUseInlineDelivery(attachments) ? preferredDelivery : "folder";
}
function capturedAttachment(file: CapturableFile, data: string): CapturedAttachment {
if (isSupportedImageMimeType(file.type)) {
return { kind: "image", name: attachmentName(file), mimeType: file.type, data, size: file.size };
}
return { kind: "file", name: attachmentName(file), mimeType: fileMimeType(file), data, size: file.size };
}
function fileMimeType(file: CapturableFile): string {
const mimeType = file.type.trim();
return mimeType === "" ? DEFAULT_FILE_MIME_TYPE : mimeType;
}
function attachmentName(file: CapturableFile): string {
return file.name !== "" ? file.name : `pasted-image.${extensionForImageMimeType(file.type)}`;
if (file.name !== "") return file.name;
if (isSupportedImageMimeType(file.type)) return `pasted-image.${extensionForImageMimeType(file.type)}`;
return "pasted-file.bin";
}