Archived
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:
@@ -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.
|
||||
@@ -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}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user