diff --git a/.changeset/attachment-sending-indicator.md b/.changeset/attachment-sending-indicator.md
index 3c7eab7..4c033b1 100644
--- a/.changeset/attachment-sending-indicator.md
+++ b/.changeset/attachment-sending-indicator.md
@@ -2,4 +2,4 @@
"@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.
+Show a sending indicator in the chat 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 existing chat activity dock now shows "Sending your message…" during that window (including the folder-mode upload step) and is superseded by the real session activity once the message lands. Attachment sending is now orchestrated in the session controller so there is a single, consistent indicator.
diff --git a/src/client/src/appState.ts b/src/client/src/appState.ts
index 9a79f4c..06c4e67 100644
--- a/src/client/src/appState.ts
+++ b/src/client/src/appState.ts
@@ -17,6 +17,7 @@ export interface AppState {
messagePageTotal: number;
isLoadingEarlierMessages: boolean;
isReceivingPartialStream: boolean;
+ isSendingPrompt: boolean;
isLoadingProjects: boolean;
isLoadingWorkspaces: boolean;
selectedProject: Project | undefined;
@@ -113,6 +114,7 @@ export function initialAppState(): AppState {
messagePageTotal: 0,
isLoadingEarlierMessages: false,
isReceivingPartialStream: false,
+ isSendingPrompt: false,
isLoadingProjects: false,
isLoadingWorkspaces: false,
selectedProject: undefined,
diff --git a/src/client/src/components/ChatView.ts b/src/client/src/components/ChatView.ts
index 89cb3bc..e81573a 100644
--- a/src/client/src/components/ChatView.ts
+++ b/src/client/src/components/ChatView.ts
@@ -48,6 +48,7 @@ export class ChatView extends LitElement {
@property({ type: Boolean }) hasMore = false;
@property({ type: Boolean }) loadingMore = false;
@property({ type: Boolean }) isReceivingPartialStream = false;
+ @property({ type: Boolean }) isSendingPrompt = false;
@property({ type: Boolean }) isCompacting = false;
@property({ type: Number }) pendingMessageCount = 0;
@property({ attribute: false }) status?: SessionStatus;
@@ -191,13 +192,22 @@ export class ChatView extends LitElement {
}
private isSessionLive(): boolean {
- return this.status?.isStreaming === true
+ return this.isSendingPrompt
+ || this.status?.isStreaming === true
|| this.status?.isCompacting === true
|| this.status?.isBashRunning === true
|| this.activity?.phase === "active";
}
private renderActivityDock() {
+ if (this.isSendingPrompt) {
+ return html`
+
+
+ Sending your message…
+
+ `;
+ }
const state = this.activityState();
if (state === undefined) return null;
const active = state !== "idle" || this.activity?.phase === "active";
diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts
index 531a89f..3b16bb4 100644
--- a/src/client/src/components/PiWebApp.ts
+++ b/src/client/src/components/PiWebApp.ts
@@ -1664,10 +1664,10 @@ export class PiWebApp extends LitElement {
if (isThinkingLevel(value)) await this.sessions.setThinkingLevel(value);
}
- private async sendPrompt(text: string, streamingBehavior?: "steer" | "followUp", attachments?: import("../api").PromptAttachment[]): Promise {
+ private sendPrompt(text: string, streamingBehavior?: "steer" | "followUp", attachments?: import("../api").PromptAttachment[], delivery?: import("../../../shared/apiTypes").PromptAttachmentDelivery): void {
const hasAttachments = attachments !== undefined && attachments.length > 0;
if (!hasAttachments && streamingBehavior === undefined && this.auth.handleSlashCommand(text)) return;
- await this.sessions.send(text, streamingBehavior, attachments);
+ void this.sessions.send(text, streamingBehavior, attachments, delivery);
}
private renderContextBar() {
@@ -1728,8 +1728,8 @@ export class PiWebApp extends LitElement {
${state.error ? html`${state.error}
` : null}
${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", 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(); }}>
+ 0} .loadingMore=${state.isLoadingEarlierMessages} .isReceivingPartialStream=${state.isReceivingPartialStream} .isSendingPrompt=${state.isSendingPrompt} .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} .sending=${state.isSendingPrompt} .onSend=${(text: string, streamingBehavior?: "steer" | "followUp", attachments?: import("../api").PromptAttachment[], delivery?: import("../../../shared/apiTypes").PromptAttachmentDelivery) => { this.sendPrompt(text, streamingBehavior, attachments, delivery); }} .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 11c3594..2e6312d 100644
--- a/src/client/src/components/PromptEditor.ts
+++ b/src/client/src/components/PromptEditor.ts
@@ -35,8 +35,8 @@ 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 | Promise;
- @property({ attribute: false }) onSaveAttachments?: (attachments: PromptAttachment[]) => Promise<{ path: string }[]>;
+ @property({ type: Boolean }) sending = false;
+ @property({ attribute: false }) onSend?: (text: string, streamingBehavior?: "steer" | "followUp", attachments?: PromptAttachment[], delivery?: PromptAttachmentDelivery) => void | Promise;
@property({ attribute: false }) onStop?: () => void;
@property({ attribute: false }) onSelectModel?: () => void;
@property({ attribute: false }) onSelectThinking?: () => void;
@@ -48,8 +48,6 @@ export class PromptEditor extends LitElement {
@state() private attachments: PendingAttachment[] = [];
@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;
@@ -87,16 +85,13 @@ export class PromptEditor extends LitElement {
const inputMode = inputModeForDraft(this.draft);
const shellMode = inputMode.kind === "shell";
const queuesInput = this.canSteer || this.isCompacting;
- const uploading = this.isSavingAttachments || this.isSending;
- const busy = this.disabled || uploading;
- const sendLabel = uploading ? "Sending…" : queuesInput ? "Queue" : "Send";
+ const busy = this.disabled || this.sending;
return html`
{ void this.handlePaste(event); }} @dragover=${(event: DragEvent) => { this.handleDragOver(event); }} @drop=${(event: DragEvent) => { void this.handleDrop(event); }}>
${shellMode ? html`
Shell command${inputMode.excludeFromContext ? " · excluded from context" : ""}
` : null}
${this.isCompacting && !shellMode ? html`
Compacting history · message will be queued
` : null}
- ${uploading ? html`
${this.isSavingAttachments ? "Saving your files…" : "Sending your files…"}
` : null}
${this.renderAttachments()}
{ this.pick(item); }}>
@@ -104,8 +99,8 @@ export class PromptEditor extends LitElement {
${this.renderCompactStatus()}
{ void this.handleFileInput(event); }} />
{ this.attachmentInput?.click(); }}>Attach
- { void this.send("followUp"); }}>${sendLabel}
- ${this.canSteer && !this.isCompacting ? html` { void this.send("steer"); }}>Steer ` : null}
+ { this.send("followUp"); }}>${queuesInput ? "Queue" : "Send"}
+ ${this.canSteer && !this.isCompacting ? html` { this.send("steer"); }}>Steer ` : null}
this.onStop?.()}>Stop
@@ -348,7 +343,7 @@ export class PromptEditor extends LitElement {
if (completion !== undefined) this.pick(completion);
return true;
}
- void this.send(this.canSteer || this.isCompacting ? "followUp" : undefined);
+ this.send(this.canSteer || this.isCompacting ? "followUp" : undefined);
return true;
}
@@ -380,52 +375,19 @@ export class PromptEditor extends LitElement {
this.completions = [];
}
- private async send(streamingBehavior?: "steer" | "followUp") {
- if (this.disabled || this.isSavingAttachments || this.isSending) return;
+ private send(streamingBehavior?: "steer" | "followUp") {
+ if (this.disabled || this.sending) return;
const text = this.draft.trim();
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;
+ const delivery = this.attachmentDelivery;
this.resetComposer();
- 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") {
- 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();
- await this.onSend?.(body, behavior);
- } catch (error) {
- this.attachmentError = error instanceof Error ? error.message : String(error);
- } finally {
- this.isSavingAttachments = false;
- }
+ // Sending is owned by the controller (it drives the chat activity dock and,
+ // for folder mode, orchestrates the upload + reference rewrite), so this is
+ // fire-and-forget here.
+ void this.onSend?.(text, behavior, attachments, attachments === undefined ? undefined : delivery);
}
private resetComposer() {
diff --git a/src/client/src/components/shared.ts b/src/client/src/components/shared.ts
index 035ca7a..aa7f8ba 100644
--- a/src/client/src/components/shared.ts
+++ b/src/client/src/components/shared.ts
@@ -459,7 +459,6 @@ 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; }
diff --git a/src/client/src/controllers/sessionController.test.ts b/src/client/src/controllers/sessionController.test.ts
index 820d9a7..245654f 100644
--- a/src/client/src/controllers/sessionController.test.ts
+++ b/src/client/src/controllers/sessionController.test.ts
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it } from "vitest";
-import { api as defaultApi, type MessagePage, type SessionActivity, type SessionInfo, type SessionRef, type SessionStatus, type Workspace } from "../api";
+import { api as defaultApi, type MessagePage, type PromptAttachment, type SessionActivity, type SessionInfo, type SessionRef, type SessionStatus, type Workspace } from "../api";
import { isCachedNewSessionInfo, loadCachedNewSessions, markCachedNewSessionInfo, rememberCachedNewSession } from "../cachedNewSessions";
import { initialAppState, type AppState } from "../appState";
import { machineSessionKey } from "../machineKeys";
@@ -142,6 +142,84 @@ describe("SessionController", () => {
expect(state.selectedSession?.messageCount).toBe(3);
});
+ it("toggles the sending state around an inline attachment send and forwards attachments", async () => {
+ let resolvePrompt: (() => void) | undefined;
+ const sendingDuringPrompt: boolean[] = [];
+ let promptArgs: { attachments?: PromptAttachment[] } | undefined;
+ let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, selectedSession: oldSession, sessions: [oldSession] };
+ const attachments: PromptAttachment[] = [{ kind: "image", mimeType: "image/png", data: "QUJD", name: "shot.png" }];
+ const api: typeof defaultApi = {
+ ...defaultApi,
+ prompt: (_session, _text, _behavior, _machineId, sentAttachments) => new Promise<{ accepted: true }>((resolve) => {
+ promptArgs = { ...(sentAttachments === undefined ? {} : { attachments: sentAttachments }) };
+ resolvePrompt = () => { resolve({ accepted: true }); };
+ }),
+ };
+ const controller = new SessionController(
+ () => state,
+ (patch) => { state = { ...state, ...patch }; },
+ () => undefined,
+ undefined,
+ { api, socket: new FakeSocket() },
+ );
+
+ const send = controller.send("look", undefined, attachments, "inline");
+ sendingDuringPrompt.push(state.isSendingPrompt);
+ resolvePrompt?.();
+ await send;
+
+ expect(sendingDuringPrompt).toEqual([true]);
+ expect(state.isSendingPrompt).toBe(false);
+ expect(promptArgs).toEqual({ attachments });
+ });
+
+ it("uploads to the workspace folder and rewrites the prompt for folder delivery", async () => {
+ let savedCalledWith: PromptAttachment[] | undefined;
+ let promptText: string | undefined;
+ let promptAttachments: PromptAttachment[] | undefined;
+ let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, selectedSession: oldSession, sessions: [oldSession] };
+ const attachments: PromptAttachment[] = [{ kind: "image", mimeType: "image/png", data: "QUJD", name: "shot.png" }];
+ const api: typeof defaultApi = {
+ ...defaultApi,
+ saveAttachments: (_session, sent) => { savedCalledWith = sent; return Promise.resolve([{ path: ".pi-web/paste/shot.png", mimeType: "image/png", size: 3 }]); },
+ prompt: (_session, text, _behavior, _machineId, sentAttachments) => { promptText = text; promptAttachments = sentAttachments; return Promise.resolve({ accepted: true }); },
+ };
+ const controller = new SessionController(
+ () => state,
+ (patch) => { state = { ...state, ...patch }; },
+ () => undefined,
+ undefined,
+ { api, socket: new FakeSocket() },
+ );
+
+ await controller.send("check this", undefined, attachments, "folder");
+
+ expect(savedCalledWith).toEqual(attachments);
+ expect(promptText).toBe("check this\n\n@.pi-web/paste/shot.png");
+ expect(promptAttachments).toBeUndefined();
+ expect(state.isSendingPrompt).toBe(false);
+ });
+
+ it("does not set the sending state for plain text messages", async () => {
+ let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, selectedSession: oldSession, sessions: [oldSession] };
+ const seen: boolean[] = [];
+ const api: typeof defaultApi = {
+ ...defaultApi,
+ prompt: () => { seen.push(state.isSendingPrompt); return Promise.resolve({ accepted: true }); },
+ };
+ const controller = new SessionController(
+ () => state,
+ (patch) => { state = { ...state, ...patch }; },
+ () => undefined,
+ undefined,
+ { api, socket: new FakeSocket() },
+ );
+
+ await controller.send("hello");
+ expect(seen).toEqual([false]);
+ expect(state.isSendingPrompt).toBe(false);
+ });
+
it("keeps live message count updates when a cached new session becomes persisted", async () => {
const cachedSession = markCachedNewSessionInfo(oldSession);
let resolvePrompt: (() => void) | undefined;
diff --git a/src/client/src/controllers/sessionController.ts b/src/client/src/controllers/sessionController.ts
index 96d8b00..554488b 100644
--- a/src/client/src/controllers/sessionController.ts
+++ b/src/client/src/controllers/sessionController.ts
@@ -6,6 +6,7 @@ import { machineSessionKey } from "../machineKeys";
import { clearDraft, moveDraft, saveDraft } from "../promptDraftStorage";
import { ChatTranscriptStore } from "../chatTranscriptStore";
import { isShellInput } from "../inputModes";
+import { fileCompletionInsertText } from "../promptCompletions";
import { SessionSocket, type GlobalSessionEvent, type SessionUiEvent } from "../sessionSocket";
import { isSessionActive } from "../../../shared/activity";
import { PI_WEB_CAPABILITIES, supportsPiWebCapability } from "../../../shared/capabilities";
@@ -63,7 +64,7 @@ export class SessionController {
this.socket.close();
this.catchupStreamSessionId = undefined;
this.clearPendingTranscriptEvents();
- this.setState({ selectedSession: undefined, messages: [], messagePageStart: 0, messagePageEnd: 0, messagePageTotal: 0, isLoadingEarlierMessages: false, isReceivingPartialStream: false, status: undefined, activity: undefined });
+ this.setState({ selectedSession: undefined, messages: [], messagePageStart: 0, messagePageEnd: 0, messagePageTotal: 0, isLoadingEarlierMessages: false, isReceivingPartialStream: false, isSendingPrompt: false, status: undefined, activity: undefined });
}
deselectSession(options?: { forgetRememberedSelection?: boolean | undefined; updateUrl?: boolean | undefined }) {
@@ -168,18 +169,32 @@ export class SessionController {
}
}
- async send(text: string, streamingBehavior?: "steer" | "followUp", attachments?: PromptAttachment[]) {
+ async send(text: string, streamingBehavior?: "steer" | "followUp", attachments?: PromptAttachment[], delivery: "inline" | "folder" = "inline") {
const trimmed = text.trim();
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;
+ // Surface a single optimistic sending state in the chat activity dock. It
+ // covers the pre-receipt window (upload, server-side image resizing,
+ // first-session open) and is superseded by real server activity/messages
+ // once api.prompt resolves.
+ if (hasAttachments) this.setState({ isSendingPrompt: true });
try {
- await this.api.prompt(session, text, streamingBehavior, selectedMachineId(this.getState()), attachments);
+ if (hasAttachments && delivery === "folder") {
+ const saved = await this.api.saveAttachments(session, attachments, selectedMachineId(this.getState()));
+ const references = saved.map((file) => fileCompletionInsertText(file.path, false)).join(" ");
+ const body = text === "" ? references : `${text}\n\n${references}`;
+ await this.api.prompt(session, body, streamingBehavior, selectedMachineId(this.getState()));
+ } else {
+ await this.api.prompt(session, text, streamingBehavior, selectedMachineId(this.getState()), attachments);
+ }
this.markCachedNewSessionPersisted(session);
} catch (error) {
this.setState({ error: String(error) });
+ } finally {
+ if (hasAttachments) this.setState({ isSendingPrompt: false });
}
}
@@ -195,12 +210,6 @@ 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;