From d17050e144e57c4acf176cd077be6d410bd014e5 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Sat, 13 Jun 2026 13:49:39 +0200 Subject: [PATCH] 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 --- .changeset/prompt-image-attachments.md | 5 + .gitignore | 3 + README.md | 1 + src/client/src/api.ts | 2 +- src/client/src/api/clients.ts | 6 +- src/client/src/api/parsers.ts | 12 +- src/client/src/attachmentPreferences.ts | 27 +++ src/client/src/chatMessages.test.ts | 12 ++ src/client/src/chatMessages.ts | 7 +- src/client/src/components/ChatView.ts | 1 + src/client/src/components/PiWebApp.ts | 9 +- src/client/src/components/PromptEditor.ts | 185 +++++++++++++++++- src/client/src/components/shared.ts | 8 + .../src/controllers/sessionController.ts | 17 +- src/config.test.ts | 21 +- src/config.ts | 28 +++ src/server/app.ts | 4 +- src/server/index.ts | 4 +- src/server/sessiond.ts | 3 +- src/server/sessions/attachmentService.test.ts | 58 ++++++ src/server/sessions/attachmentService.ts | 84 ++++++++ src/server/sessions/piSessionService.ts | 62 ++++-- src/server/sessions/sessionRoutes.test.ts | 35 +++- src/server/sessions/sessionRoutes.ts | 21 +- src/shared/apiTypes.ts | 38 ++++ src/shared/capabilities.ts | 5 +- src/shared/federatedRoutes.ts | 1 + src/shared/promptAttachments.test.ts | 78 ++++++++ src/shared/promptAttachments.ts | 85 ++++++++ 29 files changed, 776 insertions(+), 46 deletions(-) create mode 100644 .changeset/prompt-image-attachments.md create mode 100644 src/client/src/attachmentPreferences.ts create mode 100644 src/server/sessions/attachmentService.test.ts create mode 100644 src/server/sessions/attachmentService.ts create mode 100644 src/shared/promptAttachments.test.ts create mode 100644 src/shared/promptAttachments.ts diff --git a/.changeset/prompt-image-attachments.md b/.changeset/prompt-image-attachments.md new file mode 100644 index 0000000..14010f0 --- /dev/null +++ b/.changeset/prompt-image-attachments.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": minor +--- + +Add image attachments to the chat composer. You can now paste (Ctrl/Cmd+V), drag-and-drop, or use the new Attach button to add PNG, JPEG, GIF, and WebP images to a message, with thumbnail previews and multi-image support. Attachments are delivered to the session using pi's native image format (images are auto-resized to pi's inline limits for full compatibility), and image content now renders inline in the transcript. A per-message delivery toggle also lets you instead 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 now configurable via `PI_WEB_MAX_UPLOAD_BYTES` or the `maxUploadBytes` config value. diff --git a/.gitignore b/.gitignore index f430324..052ea5e 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,6 @@ dist/ # Local plugin development sandboxes. Symlink these into ~/.pi-web/plugins/. /dev-plugins/ + +# Local runtime attachment uploads (created by the chat composer "save to folder" mode). +.pi-web/ diff --git a/README.md b/README.md index e224286..d1d4e32 100644 --- a/README.md +++ b/README.md @@ -272,6 +272,7 @@ Environment variables: - `PI_WEB_SESSIOND_URL` — daemon URL used by the web process when connecting over TCP, for example `http://127.0.0.1:3001`. If you set `PI_WEB_SESSIOND_PORT`, set this for the web process too. - `PI_WEB_PROJECTS_FILE` — optional override for the projects storage JSON file. Defaults to `$PI_WEB_DATA_DIR/projects.json`. - `PI_WEB_MACHINES_FILE` — optional override for the remote machine registry JSON file. Defaults to `$PI_WEB_DATA_DIR/machines.json`. +- `PI_WEB_MAX_UPLOAD_BYTES` — maximum accepted HTTP request body size in bytes (covers pasted/attached images). Defaults to 64 MB. Also configurable as `maxUploadBytes` in `config.json`. - `PI_CODING_AGENT_SESSION_DIR` — Pi session storage directory. PI WEB follows the same session-location priority as Pi for web sessions: this environment variable, then `sessionDir` in Pi settings for the selected workspace, then Pi's default session directory. - `PI_CODING_AGENT_DIR` — Pi agent config directory. PI WEB uses this for Pi auth, settings, resources, and default session storage, matching Pi's own configuration layout. diff --git a/src/client/src/api.ts b/src/client/src/api.ts index 10e3e7e..63d4243 100644 --- a/src/client/src/api.ts +++ b/src/client/src/api.ts @@ -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"; diff --git a/src/client/src/api/clients.ts b/src/client/src/api/clients.ts index f21f1e9..3118678 100644 --- a/src/client/src/api/clients.ts +++ b/src/client/src/api/clients.ts @@ -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 }) }), diff --git a/src/client/src/api/parsers.ts b/src/client/src/api/parsers.ts index 6245a5d..2c5dfc7 100644 --- a/src/client/src/api/parsers.ts +++ b/src/client/src/api/parsers.ts @@ -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 { @@ -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"); diff --git a/src/client/src/attachmentPreferences.ts b/src/client/src/attachmentPreferences.ts new file mode 100644 index 0000000..c68cb79 --- /dev/null +++ b/src/client/src/attachmentPreferences.ts @@ -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. + } +} diff --git a/src/client/src/chatMessages.test.ts b/src/client/src/chatMessages.test.ts index a53e6a8..7d71f6f 100644 --- a/src/client/src/chatMessages.test.ts +++ b/src/client/src/chatMessages.test.ts @@ -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" } } }, diff --git a/src/client/src/chatMessages.ts b/src/client/src/chatMessages.ts index 736ae28..ceec115 100644 --- a/src/client/src/chatMessages.ts +++ b/src/client/src/chatMessages.ts @@ -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) diff --git a/src/client/src/components/ChatView.ts b/src/client/src/components/ChatView.ts index 869e5a3..89cb3bc 100644 --- a/src/client/src/components/ChatView.ts +++ b/src/client/src/components/ChatView.ts @@ -477,6 +477,7 @@ export class ChatView extends LitElement { read ${part.path} `; + if (part.type === "image") return html`attached image`; if (part.type === "toolCall") return html`
▶ ${part.toolName}${part.summary}
`; if (part.type === "toolExecution") return html``; if (part.type === "toolResult") return html` diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index af68508..713b672 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -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 {
${this.appShell.isMobileNavigationLayout ? this.renderNavigationPanel() : null}
${state.selectedSession ? html` 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())}> - 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(); }}> + 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(); }}> ${state.commandDialog !== undefined ? html` this.sessions.respondToCommand(state.commandDialog?.requestId ?? "", value)} .onCancel=${() => { this.sessions.cancelCommand(); }}>` : null} ${state.modelDialog !== undefined ? html` { void this.pickModel(value); }} .onCancel=${() => { this.setState({ modelDialog: undefined }); }}>` : null} diff --git a/src/client/src/components/PromptEditor.ts b/src/client/src/components/PromptEditor.ts index 569c18e..6083598 100644 --- a/src/client/src/components/PromptEditor.ts +++ b/src/client/src/components/PromptEditor.ts @@ -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` -