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` @@ -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;