fix: show sending indicator while uploading attachments

Messages with image attachments could take a moment to appear (large
base64 upload, server-side resize, first-session open) while the composer
cleared instantly, making it look like nothing happened.

Await the send for attachment messages and surface a "Sending…" button
label plus a "Sending your files…" / "Saving your files…" hint, disabling
the composer until the message lands. Plain text messages stay
fire-and-forget so the input frees up instantly.
This commit is contained in:
Federico Jaramillo Martinez
2026-06-13 13:57:10 +02:00
parent d17050e144
commit 53b00c47f9
4 changed files with 33 additions and 9 deletions
@@ -0,0 +1,5 @@
---
"@jmfederico/pi-web": patch
---
Show a sending indicator in the chat composer while messages with image attachments are uploading. Previously the composer cleared instantly while the upload, server-side image resizing, and first-session open happened in the background, so it looked like nothing was happening. The Send button now shows "Sending…" and a "Sending your files…" (or "Saving your files…" for folder mode) hint until the message lands, and the composer is disabled while in flight.
+3 -3
View File
@@ -1664,10 +1664,10 @@ export class PiWebApp extends LitElement {
if (isThinkingLevel(value)) await this.sessions.setThinkingLevel(value);
}
private sendPrompt(text: string, streamingBehavior?: "steer" | "followUp", attachments?: import("../api").PromptAttachment[]): void {
private async sendPrompt(text: string, streamingBehavior?: "steer" | "followUp", attachments?: import("../api").PromptAttachment[]): Promise<void> {
const hasAttachments = attachments !== undefined && attachments.length > 0;
if (!hasAttachments && streamingBehavior === undefined && this.auth.handleSlashCommand(text)) return;
void this.sessions.send(text, streamingBehavior, attachments);
await this.sessions.send(text, streamingBehavior, attachments);
}
private renderContextBar() {
@@ -1729,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", 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>
<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}
+24 -6
View File
@@ -35,7 +35,7 @@ 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", attachments?: PromptAttachment[]) => void;
@property({ attribute: false }) onSend?: (text: string, streamingBehavior?: "steer" | "followUp", attachments?: PromptAttachment[]) => void | Promise<void>;
@property({ attribute: false }) onSaveAttachments?: (attachments: PromptAttachment[]) => Promise<{ path: string }[]>;
@property({ attribute: false }) onStop?: () => void;
@property({ attribute: false }) onSelectModel?: () => void;
@@ -49,6 +49,7 @@ export class PromptEditor extends LitElement {
@state() private attachmentDelivery: PromptAttachmentDelivery = loadAttachmentDelivery();
@state() private attachmentError: string | undefined = undefined;
@state() private isSavingAttachments = false;
@state() private isSending = false;
private attachmentSeq = 0;
private requestVersion = 0;
private editor: EditorView | undefined;
@@ -86,13 +87,16 @@ 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;
const uploading = this.isSavingAttachments || this.isSending;
const busy = this.disabled || uploading;
const sendLabel = uploading ? "Sending…" : queuesInput ? "Queue" : "Send";
return html`
<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}
${uploading ? html`<div class="mode-hint sending-hint" role="status">${this.isSavingAttachments ? "Saving your files…" : "Sending your files…"}</div>` : null}
${this.renderAttachments()}
<autocomplete-menu .items=${this.completions} .selectedIndex=${this.selectedIndex} .onPick=${(item: CompletionItem) => { this.pick(item); }}></autocomplete-menu>
</div>
@@ -100,7 +104,7 @@ export class PromptEditor extends LitElement {
${this.renderCompactStatus()}
<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>
<button ?disabled=${busy} title=${queuesInput ? "Queue until the current activity finishes" : "Send message"} @click=${() => { void this.send("followUp"); }}>${sendLabel}</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>
@@ -377,7 +381,7 @@ export class PromptEditor extends LitElement {
}
private async send(streamingBehavior?: "steer" | "followUp") {
if (this.disabled || this.isSavingAttachments) return;
if (this.disabled || this.isSavingAttachments || this.isSending) return;
const text = this.draft.trim();
const pending = this.attachments;
if (text === "" && pending.length === 0) return;
@@ -390,7 +394,21 @@ export class PromptEditor extends LitElement {
const attachments = pending.length > 0 ? this.currentAttachments() : undefined;
this.resetComposer();
this.onSend?.(text, behavior, attachments);
if (attachments === undefined) {
// Plain text messages stay fire-and-forget so the input frees up instantly.
void this.onSend?.(text, behavior, attachments);
return;
}
// Image uploads can take a moment (large payloads, server-side resizing,
// first-session open), so surface a sending indicator until they land.
this.isSending = true;
try {
await this.onSend?.(text, behavior, attachments);
} catch (error) {
this.attachmentError = error instanceof Error ? error.message : String(error);
} finally {
this.isSending = false;
}
}
private async sendWithFolderAttachments(text: string, behavior?: "steer" | "followUp") {
@@ -402,7 +420,7 @@ export class PromptEditor extends LitElement {
const references = saved.map((file) => fileCompletionInsertText(file.path, false)).join(" ");
const body = text === "" ? references : `${text}\n\n${references}`;
this.resetComposer();
this.onSend?.(body, behavior);
await this.onSend?.(body, behavior);
} catch (error) {
this.attachmentError = error instanceof Error ? error.message : String(error);
} finally {
+1
View File
@@ -459,6 +459,7 @@ 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; }
.sending-hint { border-color: var(--pi-accent-border); background: var(--pi-selection-bg); color: var(--pi-accent); }
.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; }