feat: add image attachments to the chat composer

Support pasting (Ctrl/Cmd+V), drag-and-drop, and an Attach button to add
PNG/JPEG/GIF/WebP images to a message, with thumbnail previews and
multi-image support.

Attachments are delivered to the session using pi's native ImageContent
format and are run through pi's own resizeImage so they match pi's inline
image limits exactly. Image content now renders inline in the transcript.

A per-message delivery toggle also lets users save attachments into the
workspace `.pi-web/paste` folder and reference them so the agent reads
them with its own tools.

The accepted HTTP upload size is configurable via PI_WEB_MAX_UPLOAD_BYTES
or the maxUploadBytes config value (default 64 MB).

Closes #13
This commit is contained in:
Federico Jaramillo Martinez
2026-06-13 13:49:39 +02:00
parent 847510e240
commit d17050e144
29 changed files with 776 additions and 46 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
export { activityApi, api, configApi, filesApi, gitApi, machinesApi, piWebApi, pluginsApi, projectsApi, sessionsApi, terminalsApi, workspacesApi } from "./api/clients";
export { globalSessionEvents, realtimeEvents, sessionEvents, terminalSocket } from "./api/sockets";
export type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, FileContentMediaType, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, OAuthFlowState, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebPluginConfig, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebPluginSettings, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, Project, QueuedSessionMessage, RealtimeEvent, RunTerminalCommandInput, SessionActivity, SessionInfo, SessionRef, SessionModel, SessionStatus, SlashCommand, SessionUiEvent, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, TerminalCommandRunStatus, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse, WorkspaceActivityUiEvent } from "../../shared/apiTypes";
export type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, FileContentMediaType, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, OAuthFlowState, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebPluginConfig, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebPluginSettings, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, Project, PromptAttachment, QueuedSessionMessage, RealtimeEvent, SavedPromptAttachment, RunTerminalCommandInput, SessionActivity, SessionInfo, SessionRef, SessionModel, SessionStatus, SlashCommand, SessionUiEvent, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, TerminalCommandRunStatus, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse, WorkspaceActivityUiEvent } from "../../shared/apiTypes";
+4 -2
View File
@@ -1,4 +1,4 @@
import type { FileSuggestion, PiWebConfigValues, RunTerminalCommandInput, SessionRef, TerminalCommandRun, TerminalCommandRunFilter } from "../../../shared/apiTypes";
import type { FileSuggestion, PiWebConfigValues, PromptAttachment, RunTerminalCommandInput, SessionRef, TerminalCommandRun, TerminalCommandRunFilter } from "../../../shared/apiTypes";
import { request } from "./http";
import {
arrayOf,
@@ -28,6 +28,7 @@ import {
parsePiWebStatusResponse,
parseProject,
parseRestored,
parseSavedAttachments,
parseSessionInfo,
parseSessionStatus,
parseSlashCommand,
@@ -130,7 +131,8 @@ export const sessionsApi = {
setThinkingLevel: (session: SessionLookup, level: "off" | "minimal" | "low" | "medium" | "high" | "xhigh", machineId = "local") => request(sessionUrl(session, "thinking-level", machineId), parseSessionStatus, { method: "POST", body: sessionBody(session, { level }) }),
cycleThinkingLevel: (session: SessionLookup, machineId = "local") => request(sessionUrl(session, "thinking-level/cycle", machineId), parseSessionStatus, { method: "POST", body: sessionBody(session) }),
commands: (session: SessionLookup, machineId = "local") => request(sessionQueryUrl(session, "commands", machineId), arrayOf(parseSlashCommand)),
prompt: (session: SessionLookup, text: string, streamingBehavior?: "steer" | "followUp", machineId = "local") => request(sessionUrl(session, "prompt", machineId), parseAccepted, { method: "POST", body: sessionBody(session, streamingBehavior === undefined ? { text } : { text, streamingBehavior }) }),
prompt: (session: SessionLookup, text: string, streamingBehavior?: "steer" | "followUp", machineId = "local", attachments?: PromptAttachment[]) => request(sessionUrl(session, "prompt", machineId), parseAccepted, { method: "POST", body: sessionBody(session, { text, ...(streamingBehavior === undefined ? {} : { streamingBehavior }), ...(attachments !== undefined && attachments.length > 0 ? { attachments } : {}) }) }),
saveAttachments: (session: SessionLookup, attachments: PromptAttachment[], machineId = "local", folder?: string) => request(sessionUrl(session, "attachments", machineId), parseSavedAttachments, { method: "POST", body: sessionBody(session, { attachments, ...(folder === undefined ? {} : { folder }) }) }),
shell: (session: SessionLookup, text: string, machineId = "local") => request(sessionUrl(session, "shell", machineId), parseAccepted, { method: "POST", body: sessionBody(session, { text }) }),
runCommand: (session: SessionLookup, text: string, machineId = "local") => request(sessionUrl(session, "commands/run", machineId), parseCommandResult, { method: "POST", body: sessionBody(session, { text }) }),
respondToCommand: (session: SessionLookup, requestId: string, value: string, machineId = "local") => request(sessionUrl(session, "commands/respond", machineId), parseCommandResult, { method: "POST", body: sessionBody(session, { requestId, value }) }),
+11 -1
View File
@@ -1,4 +1,4 @@
import type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, OAuthFlowState, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebServiceComponent, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebStatusSeverity, Project, QueuedSessionMessage, SessionInfo, SessionModel, SessionStatus, SlashCommand, TerminalCommandRun, TerminalCommandRunStatus, TerminalInfo, ThinkingLevel, ThinkingLevelsResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse } from "../../../shared/apiTypes";
import type { ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, OAuthFlowState, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebInstallationInfo, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebServiceComponent, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebStatusSeverity, Project, QueuedSessionMessage, SavedPromptAttachment, SessionInfo, SessionModel, SessionStatus, SlashCommand, TerminalCommandRun, TerminalCommandRunStatus, TerminalInfo, ThinkingLevel, ThinkingLevelsResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse } from "../../../shared/apiTypes";
import { isPiWebCapability } from "../../../shared/capabilities";
function isRecord(value: unknown): value is Record<string, unknown> {
@@ -655,6 +655,16 @@ export function parseAccepted(value: unknown): { accepted: true } {
return { accepted: true };
}
export function parseSavedAttachments(value: unknown): SavedPromptAttachment[] {
const record = requireRecord(value);
return arrayOf(parseSavedAttachment)(record["attachments"]);
}
function parseSavedAttachment(value: unknown): SavedPromptAttachment {
const record = requireRecord(value);
return { path: requireString(record, "path"), mimeType: requireString(record, "mimeType"), size: requireNumber(record, "size") };
}
export function parseClosed(value: unknown): { closed: true } {
const record = requireRecord(value);
if (record["closed"] !== true) throw new Error("Expected closed response");
+27
View File
@@ -0,0 +1,27 @@
import type { PromptAttachmentDelivery } from "../../shared/apiTypes";
const storageKey = "pi-web:attachment-delivery";
function browserStorage(): Storage | undefined {
try {
return typeof localStorage === "undefined" ? undefined : localStorage;
} catch {
return undefined;
}
}
export function loadAttachmentDelivery(storage = browserStorage()): PromptAttachmentDelivery {
try {
return storage?.getItem(storageKey) === "folder" ? "folder" : "inline";
} catch {
return "inline";
}
}
export function saveAttachmentDelivery(mode: PromptAttachmentDelivery, storage = browserStorage()): void {
try {
storage?.setItem(storageKey, mode);
} catch {
// Ignore localStorage quota/privacy errors.
}
}
+12
View File
@@ -29,6 +29,18 @@ describe("chat message normalization", () => {
]);
});
it("normalizes image content into image parts", () => {
expect(normalizeMessage({ role: "user", content: [{ type: "text", text: "see this" }, { type: "image", mimeType: "image/png", data: "QUJD" }] })).toEqual([
{ role: "user", parts: [{ type: "text", text: "see this" }, { type: "image", mimeType: "image/png", data: "QUJD" }] },
]);
});
it("falls back to a placeholder for image content without data", () => {
expect(normalizeMessage({ role: "user", content: [{ type: "image", mimeType: "image/png" }] })).toEqual([
{ role: "user", parts: [{ type: "text", text: "[image]" }] },
]);
});
it("shows assistant model errors as system chat messages", () => {
expect(normalizeMessage({ role: "assistant", content: [], stopReason: "error", errorMessage: "429 rate limit", timestamp: "2026-05-09T12:00:00.000Z", provider: "openai", model: "gpt-4.1" })).toEqual([
{ role: "system", parts: [{ type: "text", text: "Model response failed: 429 rate limit" }], meta: { timestamp: "2026-05-09T12:00:00.000Z", model: { provider: "openai", id: "gpt-4.1" } } },
+6 -1
View File
@@ -167,7 +167,12 @@ function normalizeContent(content: unknown, message: unknown): ChatPart[] {
const toolCallId = getString(part, "id");
return [{ type: "toolCall", ...(toolCallId === undefined ? {} : { toolCallId }), toolName, summary: summarizeArgs(args), ...(args === undefined ? {} : { args }) }];
}
if (type === "image") return [{ type: "text", text: "[image]" }];
if (type === "image") {
const data = getString(part, "data");
const mimeType = getString(part, "mimeType");
if (data !== undefined && data !== "" && mimeType !== undefined && mimeType !== "") return [{ type: "image", mimeType, data }];
return [{ type: "text", text: "[image]" }];
}
return objectFallback(part);
}).map((part) => part.type === "text" && getString(message, "role") === "toolResult"
? toolResultPartFromText(part.text, message)
+1
View File
@@ -477,6 +477,7 @@ export class ChatView extends LitElement {
<small>read ${part.path}</small>
</div>
`;
if (part.type === "image") return html`<img class="part chat-image" src=${`data:${part.mimeType};base64,${part.data}`} alt="attached image" loading="lazy" />`;
if (part.type === "toolCall") return html`<div class="part tool-line">▶ ${part.toolName}<span class="summary">${part.summary}</span></div>`;
if (part.type === "toolExecution") return html`<tool-execution-view class="part" .execution=${part}></tool-execution-view>`;
if (part.type === "toolResult") return html`
+5 -4
View File
@@ -1664,9 +1664,10 @@ export class PiWebApp extends LitElement {
if (isThinkingLevel(value)) await this.sessions.setThinkingLevel(value);
}
private sendPrompt(text: string, streamingBehavior?: "steer" | "followUp"): void {
if (streamingBehavior === undefined && this.auth.handleSlashCommand(text)) return;
void this.sessions.send(text, streamingBehavior);
private sendPrompt(text: string, streamingBehavior?: "steer" | "followUp", attachments?: import("../api").PromptAttachment[]): void {
const hasAttachments = attachments !== undefined && attachments.length > 0;
if (!hasAttachments && streamingBehavior === undefined && this.auth.handleSlashCommand(text)) return;
void this.sessions.send(text, streamingBehavior, attachments);
}
private renderContextBar() {
@@ -1728,7 +1729,7 @@ export class PiWebApp extends LitElement {
<div class="mobile-navigation-panel">${this.appShell.isMobileNavigationLayout ? this.renderNavigationPanel() : null}</div>
${state.selectedSession ? html`
<chat-view .sessionId=${state.selectedSession.id} .messages=${state.messages} .messageStart=${state.messagePageStart} .messageEnd=${state.messagePageEnd} .messageTotal=${state.messagePageTotal} .hasMore=${state.messagePageStart > 0} .loadingMore=${state.isLoadingEarlierMessages} .isReceivingPartialStream=${state.isReceivingPartialStream} .isCompacting=${state.status?.isCompacting === true} .pendingMessageCount=${state.status?.pendingMessageCount ?? 0} .status=${state.status} .activity=${state.activity} .onLoadMore=${() => this.withChatPrependTransition(() => this.sessions.loadEarlierMessages())}></chat-view>
<prompt-editor .sessionId=${state.selectedSession.id} .cwd=${state.selectedWorkspace?.path} .machineId=${selectedMachineId(state)} .disabled=${state.selectedSession.archived === true} .canSteer=${state.status?.isStreaming === true} .isCompacting=${state.status?.isCompacting === true} .canStop=${state.status?.isStreaming === true || state.status?.isBashRunning === true || state.status?.isCompacting === true || (state.status?.pendingMessageCount ?? 0) > 0} .status=${state.status} .onSend=${(text: string, streamingBehavior?: "steer" | "followUp") => { this.sendPrompt(text, streamingBehavior); }} .onStop=${() => this.sessions.stopActiveWork()} .onSelectModel=${() => { void this.openModelDialog(); }} .onSelectThinking=${() => { void this.openThinkingDialog(); }}></prompt-editor>
<prompt-editor .sessionId=${state.selectedSession.id} .cwd=${state.selectedWorkspace?.path} .machineId=${selectedMachineId(state)} .disabled=${state.selectedSession.archived === true} .canSteer=${state.status?.isStreaming === true} .isCompacting=${state.status?.isCompacting === true} .canStop=${state.status?.isStreaming === true || state.status?.isBashRunning === true || state.status?.isCompacting === true || (state.status?.pendingMessageCount ?? 0) > 0} .status=${state.status} .onSend=${(text: string, streamingBehavior?: "steer" | "followUp", attachments?: import("../api").PromptAttachment[]) => { this.sendPrompt(text, streamingBehavior, attachments); }} .onSaveAttachments=${(attachments: import("../api").PromptAttachment[]) => this.sessions.saveAttachments(attachments)} .onStop=${() => this.sessions.stopActiveWork()} .onSelectModel=${() => { void this.openModelDialog(); }} .onSelectThinking=${() => { void this.openThinkingDialog(); }}></prompt-editor>
<status-bar .status=${state.status}></status-bar>
${state.commandDialog !== undefined ? html`<command-picker .title=${state.commandDialog.title} .options=${state.commandDialog.options} .onPick=${(value: string) => this.sessions.respondToCommand(state.commandDialog?.requestId ?? "", value)} .onCancel=${() => { this.sessions.cancelCommand(); }}></command-picker>` : null}
${state.modelDialog !== undefined ? html`<command-picker title=${state.modelDialog.title} .searchable=${true} .options=${state.modelDialog.options} .selectedValue=${state.modelDialog.selectedValue} .onPick=${(value: string) => { void this.pickModel(value); }} .onCancel=${() => { this.setState({ modelDialog: undefined }); }}></command-picker>` : null}
+176 -9
View File
@@ -5,14 +5,26 @@ import { EditorView, keymap, placeholder } from "@codemirror/view";
import { defaultHighlightStyle, indentOnInput, indentUnit, syntaxHighlighting } from "@codemirror/language";
import { LitElement, html, type PropertyValues } from "lit";
import { customElement, property, query, state } from "lit/decorators.js";
import { api, type FileSuggestion, type SessionStatus, type SlashCommand } from "../api";
import { api, type FileSuggestion, type PromptAttachment, type SessionStatus, type SlashCommand } from "../api";
import type { PromptAttachmentDelivery } from "../../../shared/apiTypes";
import { isSupportedImageMimeType } from "../../../shared/promptAttachments";
import { inputModeForDraft } from "../inputModes";
import { machineSessionKey } from "../machineKeys";
import { detectPromptCompletionTrigger, fileCompletionInsertText, type PromptCompletionTrigger } from "../promptCompletions";
import { clearDraft, loadDraft, saveDraft } from "../promptDraftStorage";
import { loadAttachmentDelivery, saveAttachmentDelivery } from "../attachmentPreferences";
import { promptEditorStyles, type CompletionItem } from "./shared";
import "./AutocompleteMenu";
interface PendingAttachment {
id: string;
name: string;
mimeType: string;
/** Base64 payload without the data: URL prefix. */
data: string;
size: number;
}
@customElement("prompt-editor")
export class PromptEditor extends LitElement {
@property({ type: Boolean }) disabled = false;
@@ -23,14 +35,21 @@ export class PromptEditor extends LitElement {
@property({ type: Boolean }) isCompacting = false;
@property({ type: Boolean }) canStop = false;
@property({ attribute: false }) status?: SessionStatus;
@property({ attribute: false }) onSend?: (text: string, streamingBehavior?: "steer" | "followUp") => void;
@property({ attribute: false }) onSend?: (text: string, streamingBehavior?: "steer" | "followUp", attachments?: PromptAttachment[]) => void;
@property({ attribute: false }) onSaveAttachments?: (attachments: PromptAttachment[]) => Promise<{ path: string }[]>;
@property({ attribute: false }) onStop?: () => void;
@property({ attribute: false }) onSelectModel?: () => void;
@property({ attribute: false }) onSelectThinking?: () => void;
@query(".markdown-editor") private editorHost?: HTMLDivElement;
@query(".attachment-input") private attachmentInput?: HTMLInputElement;
@state() private draft = "";
@state() private completions: CompletionItem[] = [];
@state() private selectedIndex = 0;
@state() private attachments: PendingAttachment[] = [];
@state() private attachmentDelivery: PromptAttachmentDelivery = loadAttachmentDelivery();
@state() private attachmentError: string | undefined = undefined;
@state() private isSavingAttachments = false;
private attachmentSeq = 0;
private requestVersion = 0;
private editor: EditorView | undefined;
private readonly editableCompartment = new Compartment();
@@ -67,18 +86,22 @@ export class PromptEditor extends LitElement {
const inputMode = inputModeForDraft(this.draft);
const shellMode = inputMode.kind === "shell";
const queuesInput = this.canSteer || this.isCompacting;
const busy = this.disabled || this.isSavingAttachments;
return html`
<footer class=${shellMode ? "shell-mode" : ""}>
<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>
${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()}
<autocomplete-menu .items=${this.completions} .selectedIndex=${this.selectedIndex} .onPick=${(item: CompletionItem) => { this.pick(item); }}></autocomplete-menu>
</div>
<div class="actions">
${this.renderCompactStatus()}
<button ?disabled=${this.disabled} title=${queuesInput ? "Queue until the current activity finishes" : "Send message"} @click=${() => { this.send("followUp"); }}>${queuesInput ? "Queue" : "Send"}</button>
${this.canSteer && !this.isCompacting ? html`<button ?disabled=${this.disabled} title="Steer the current response before the next model call" @click=${() => { this.send("steer"); }}>Steer</button>` : null}
<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="attach-button" ?disabled=${busy} title="Attach images" @click=${() => { this.attachmentInput?.click(); }}>Attach</button>
<button ?disabled=${busy} title=${queuesInput ? "Queue until the current activity finishes" : "Send message"} @click=${() => { void this.send("followUp"); }}>${queuesInput ? "Queue" : "Send"}</button>
${this.canSteer && !this.isCompacting ? html`<button ?disabled=${busy} title="Steer the current response before the next model call" @click=${() => { void this.send("steer"); }}>Steer</button>` : null}
<button ?disabled=${this.disabled || !this.canStop} title=${this.canStop ? "Stop current work and clear queued messages" : "Nothing running"} @click=${() => this.onStop?.()}>Stop</button>
</div>
</footer>
@@ -102,6 +125,98 @@ export class PromptEditor extends LitElement {
`;
}
private renderAttachments() {
if (this.attachments.length === 0 && this.attachmentError === undefined) return null;
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} />
<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>
<option value="folder">Save to .pi-web/paste</option>
</select>
</label>
` : null}
${this.attachmentError !== undefined ? html`<div class="attachment-error">${this.attachmentError}</div>` : null}
</div>
`;
}
private changeDelivery(event: Event) {
if (!(event.target instanceof HTMLSelectElement)) return;
this.attachmentDelivery = event.target.value === "folder" ? "folder" : "inline";
saveAttachmentDelivery(this.attachmentDelivery);
}
private removeAttachment(id: string) {
this.attachments = this.attachments.filter((attachment) => attachment.id !== id);
}
private async handlePaste(event: ClipboardEvent) {
const files = imageFilesFromDataTransfer(event.clipboardData);
if (files.length === 0) return;
event.preventDefault();
await this.addAttachmentFiles(files);
}
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();
}
}
private async handleDrop(event: DragEvent) {
const files = imageFilesFromDataTransfer(event.dataTransfer);
if (files.length === 0) return;
event.preventDefault();
await this.addAttachmentFiles(files);
}
private async handleFileInput(event: Event) {
if (!(event.target instanceof HTMLInputElement) || event.target.files === null) return;
const files = Array.from(event.target.files);
event.target.value = "";
await this.addAttachmentFiles(files);
}
private async addAttachmentFiles(files: File[]) {
this.attachmentError = undefined;
for (const file of files) {
if (!isSupportedImageMimeType(file.type)) {
this.attachmentError = "Only PNG, JPEG, GIF, and WebP images are supported.";
continue;
}
try {
const data = await readFileAsBase64(file);
this.attachments = [...this.attachments, {
id: `attachment-${String(++this.attachmentSeq)}`,
name: file.name !== "" ? file.name : `pasted-image.${file.type.split("/")[1] ?? "png"}`,
mimeType: file.type,
data,
size: file.size,
}];
} catch {
this.attachmentError = "Failed to read an attachment.";
}
}
}
private currentAttachments(): PromptAttachment[] {
return this.attachments.map((attachment) => ({
kind: "image",
mimeType: attachment.mimeType,
data: attachment.data,
name: attachment.name,
}));
}
private createEditor() {
if (!this.editorHost || this.editor !== undefined) return;
this.editor = new EditorView({
@@ -229,7 +344,7 @@ export class PromptEditor extends LitElement {
if (completion !== undefined) this.pick(completion);
return true;
}
this.send(this.canSteer || this.isCompacting ? "followUp" : undefined);
void this.send(this.canSteer || this.isCompacting ? "followUp" : undefined);
return true;
}
@@ -261,14 +376,47 @@ export class PromptEditor extends LitElement {
this.completions = [];
}
private send(streamingBehavior?: "steer" | "followUp") {
private async send(streamingBehavior?: "steer" | "followUp") {
if (this.disabled || this.isSavingAttachments) return;
const text = this.draft.trim();
if (text === "" || this.disabled) return;
const pending = this.attachments;
if (text === "" && pending.length === 0) return;
const behavior = this.canSteer || this.isCompacting ? streamingBehavior : undefined;
if (pending.length > 0 && this.attachmentDelivery === "folder") {
await this.sendWithFolderAttachments(text, behavior);
return;
}
const attachments = pending.length > 0 ? this.currentAttachments() : undefined;
this.resetComposer();
this.onSend?.(text, behavior, attachments);
}
private async sendWithFolderAttachments(text: string, behavior?: "steer" | "followUp") {
if (this.onSaveAttachments === undefined) return;
this.isSavingAttachments = true;
this.attachmentError = undefined;
try {
const saved = await this.onSaveAttachments(this.currentAttachments());
const references = saved.map((file) => fileCompletionInsertText(file.path, false)).join(" ");
const body = text === "" ? references : `${text}\n\n${references}`;
this.resetComposer();
this.onSend?.(body, behavior);
} catch (error) {
this.attachmentError = error instanceof Error ? error.message : String(error);
} finally {
this.isSavingAttachments = false;
}
}
private resetComposer() {
this.draft = "";
const key = draftStorageKey(this.machineId, this.sessionId);
if (key !== undefined) clearDraft(key);
this.completions = [];
this.onSend?.(text, this.canSteer || this.isCompacting ? streamingBehavior : undefined);
this.attachments = [];
this.attachmentError = undefined;
}
static override styles = promptEditorStyles;
@@ -288,6 +436,25 @@ function emptyFileSuggestions(): FileSuggestion[] {
return [];
}
function imageFilesFromDataTransfer(data: DataTransfer | null): File[] {
if (data === null) return [];
return Array.from(data.files).filter((file) => file.type.startsWith("image/"));
}
function readFileAsBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onerror = () => { reject(reader.error ?? new Error("Failed to read file")); };
reader.onload = () => {
const result = reader.result;
if (typeof result !== "string") { reject(new Error("Unexpected file reader result")); return; }
const commaIndex = result.indexOf(",");
resolve(commaIndex === -1 ? result : result.slice(commaIndex + 1));
};
reader.readAsDataURL(file);
});
}
const proseInputAssistanceAttributes: Record<string, string> = {
spellcheck: "true",
autocorrect: "on",
+8
View File
@@ -21,6 +21,7 @@ export interface ToolExecutionPart {
export type ChatPart =
| { type: "text"; text: string }
| { type: "image"; mimeType: string; data: string }
| { type: "thinking"; text: string }
| { type: "skillInvocation"; name: string; location: string; content: string }
| { type: "skillRead"; name: string; path: string }
@@ -290,6 +291,7 @@ export const chatStyles = css`
.msg.event-group.live > summary { border-bottom-color: var(--pi-success-border); background: var(--pi-success-bg); color: var(--pi-success); }
.msg.event-group > summary .label { margin: 0; }
.group-body { padding: 0 12px 12px; }
.chat-image { display: block; max-width: 100%; max-height: 320px; margin: 8px 0 0; border: 1px solid var(--pi-border-muted); border-radius: 8px; object-fit: contain; }
.group-msg { max-width: 100%; min-width: 0; box-sizing: border-box; padding: 10px 0; border-top: 1px solid var(--pi-border-muted); color: var(--pi-text); overflow: visible; }
.group-msg.tool { color: var(--pi-warning); }
.group-msg.tool-execution-shell { color: var(--pi-text); }
@@ -457,6 +459,12 @@ export const promptEditorStyles = css`
.markdown-editor .cm-focused { outline: none; }
.shell-mode textarea, .shell-mode .markdown-editor .cm-editor { border-color: var(--pi-success); box-shadow: 0 0 0 1px var(--pi-success-ring); }
.mode-hint { position: absolute; right: 8px; bottom: 8px; max-width: calc(100% - 16px); border: 1px solid var(--pi-success-border); border-radius: 999px; background: var(--pi-success-surface); color: var(--pi-success); padding: 2px 8px; font-size: 12px; pointer-events: none; }
.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-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; }
button { border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); color: var(--pi-text); padding: 7px 9px; cursor: pointer; }
button:disabled, textarea:disabled, .markdown-editor-disabled .cm-editor { opacity: .5; cursor: not-allowed; }
@media (max-width: 640px) {
@@ -1,4 +1,4 @@
import { api as defaultApi, type CommandResult, type SessionActivity, type SessionInfo, type SessionRef, type SessionStatus, type ThinkingLevel } from "../api";
import { api as defaultApi, type CommandResult, type PromptAttachment, type SessionActivity, type SessionInfo, type SessionRef, type SessionStatus, type ThinkingLevel } from "../api";
import type { AppState } from "../appState";
import { forgetCachedNewSession, isCachedNewSessionInfo, markCachedNewSessionInfo, rememberCachedNewSession, stripCachedNewSessionMarker } from "../cachedNewSessions";
import { textMessage } from "../chatMessages";
@@ -168,14 +168,15 @@ export class SessionController {
}
}
async send(text: string, streamingBehavior?: "steer" | "followUp") {
async send(text: string, streamingBehavior?: "steer" | "followUp", attachments?: PromptAttachment[]) {
const trimmed = text.trim();
if (trimmed.startsWith("/")) return this.runCommand(text);
if (isShellInput(text)) return this.runShell(text);
const hasAttachments = attachments !== undefined && attachments.length > 0;
if (!hasAttachments && trimmed.startsWith("/")) return this.runCommand(text);
if (!hasAttachments && isShellInput(text)) return this.runShell(text);
const session = this.getState().selectedSession;
if (!session || session.archived === true) return;
try {
await this.api.prompt(session, text, streamingBehavior, selectedMachineId(this.getState()));
await this.api.prompt(session, text, streamingBehavior, selectedMachineId(this.getState()), attachments);
this.markCachedNewSessionPersisted(session);
} catch (error) {
this.setState({ error: String(error) });
@@ -194,6 +195,12 @@ export class SessionController {
}
}
async saveAttachments(attachments: PromptAttachment[], folder?: string) {
const session = this.getState().selectedSession;
if (!session || session.archived === true || attachments.length === 0) return [];
return this.api.saveAttachments(session, attachments, selectedMachineId(this.getState()), folder);
}
async runCommand(text: string) {
const session = this.getState().selectedSession;
if (!session || session.archived === true) return;