From 886393a31e7eb6e45da28d96c89eb617a1bf97cd Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Sat, 13 Jun 2026 21:09:31 +0200 Subject: [PATCH] fix: scope the attachment sending indicator per session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit isSendingPrompt was a single global flag, so an in-flight upload showed the "Sending…" dock on whatever session/machine the user switched to. Replace it with sendingPrompts, a Record keyed like sessionStatuses/sessionActivities. The controller sets/clears the entry for the originating session (captured before the await), the chat dock reads only the selected session's entry, and the session list now shows the activity dot for a session that is uploading so progress is visible after switching away. Machine switches clear the record like other per-session state; deselecting a session no longer cancels its indicator. --- .changeset/attachment-sending-indicator.md | 2 +- src/client/src/appState.ts | 5 +- src/client/src/components/PiWebApp.ts | 5 +- src/client/src/components/SessionList.ts | 4 +- .../components/appShell/AppNavigationPanel.ts | 2 + .../src/controllers/machineController.ts | 1 + .../src/controllers/sessionController.test.ts | 46 +++++++++++++++---- .../src/controllers/sessionController.ts | 43 ++++++++++++----- 8 files changed, 81 insertions(+), 27 deletions(-) diff --git a/.changeset/attachment-sending-indicator.md b/.changeset/attachment-sending-indicator.md index 4c033b1..4bd56b7 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 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. +Show a per-session sending indicator 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 chat activity dock now shows "Sending your message…" for the originating session (including the folder-mode upload step), and that session shows the activity dot in the session list so progress is visible even after switching away. The indicator is scoped per session, so it no longer leaks onto other sessions or machines, and the upload itself continues in the background regardless of navigation. diff --git a/src/client/src/appState.ts b/src/client/src/appState.ts index 06c4e67..aec36db 100644 --- a/src/client/src/appState.ts +++ b/src/client/src/appState.ts @@ -17,7 +17,8 @@ export interface AppState { messagePageTotal: number; isLoadingEarlierMessages: boolean; isReceivingPartialStream: boolean; - isSendingPrompt: boolean; + /** Sessions with a prompt upload in flight, keyed by sessionId (client-owned). */ + sendingPrompts: Record; isLoadingProjects: boolean; isLoadingWorkspaces: boolean; selectedProject: Project | undefined; @@ -114,7 +115,7 @@ export function initialAppState(): AppState { messagePageTotal: 0, isLoadingEarlierMessages: false, isReceivingPartialStream: false, - isSendingPrompt: false, + sendingPrompts: {}, isLoadingProjects: false, isLoadingWorkspaces: false, selectedProject: undefined, diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index 3b16bb4..1dcce9e 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -1029,6 +1029,7 @@ export class PiWebApp extends LitElement { .sessions=${this.state.sessions} .sessionStatuses=${this.state.sessionStatuses} .sessionActivities=${this.state.sessionActivities} + .sendingPrompts=${this.state.sendingPrompts} .selectedSession=${this.state.selectedSession} .canStartSession=${!!this.state.selectedWorkspace} .canDeleteArchivedSessions=${this.canDeleteArchivedSessions()} @@ -1728,8 +1729,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} .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(); }}> + 0} .loadingMore=${state.isLoadingEarlierMessages} .isReceivingPartialStream=${state.isReceivingPartialStream} .isSendingPrompt=${state.sendingPrompts[state.selectedSession.id] === true} .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.sendingPrompts[state.selectedSession.id] === true} .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/SessionList.ts b/src/client/src/components/SessionList.ts index 04132fb..d7dbc49 100644 --- a/src/client/src/components/SessionList.ts +++ b/src/client/src/components/SessionList.ts @@ -27,6 +27,7 @@ export class SessionList extends LitElement implements KeyboardNavigableSection @property({ attribute: false }) sessions: SessionInfo[] = []; @property({ attribute: false }) statuses: Record = {}; @property({ attribute: false }) activities: Record = {}; + @property({ attribute: false }) sending: Record = {}; @property({ attribute: false }) selected?: SessionInfo; @property({ type: Boolean }) canStart = false; @property({ type: Boolean }) canDeleteArchived = false; @@ -361,7 +362,8 @@ export class SessionList extends LitElement implements KeyboardNavigableSection private renderActivity(session: SessionInfo) { if (isCachedNewSessionInfo(session) || session.archived === true) return undefined; - return renderActionActivityIndicator(isSessionActive(this.statuses[session.id], this.activities[session.id]) ? "session" : undefined, "Session active"); + const active = this.sending[session.id] === true || isSessionActive(this.statuses[session.id], this.activities[session.id]); + return renderActionActivityIndicator(active ? "session" : undefined, "Session active"); } static override styles = [listStyles, css` diff --git a/src/client/src/components/appShell/AppNavigationPanel.ts b/src/client/src/components/appShell/AppNavigationPanel.ts index bc4a10b..3bed5f9 100644 --- a/src/client/src/components/appShell/AppNavigationPanel.ts +++ b/src/client/src/components/appShell/AppNavigationPanel.ts @@ -28,6 +28,7 @@ export class AppNavigationPanel extends LitElement { @property({ attribute: false }) workspaceActivities: Record = {}; @property({ attribute: false }) sessionActivities: Record = {}; @property({ attribute: false }) sessionStatuses: Record = {}; + @property({ attribute: false }) sendingPrompts: Record = {}; @property({ attribute: false }) workspacesByProjectId: Record = {}; @property({ attribute: false }) deletingWorkspaceIds: string[] = []; @property({ attribute: false }) workspaceLabelItems: (workspace: Workspace) => WorkspaceLabelItem[] = () => []; @@ -151,6 +152,7 @@ export class AppNavigationPanel extends LitElement { .sessions=${this.sessions} .statuses=${this.sessionStatuses} .activities=${this.sessionActivities} + .sending=${this.sendingPrompts} .selected=${this.selectedSession} .canStart=${this.canStartSession} .canDeleteArchived=${this.canDeleteArchivedSessions} diff --git a/src/client/src/controllers/machineController.ts b/src/client/src/controllers/machineController.ts index a53221a..b508315 100644 --- a/src/client/src/controllers/machineController.ts +++ b/src/client/src/controllers/machineController.ts @@ -39,6 +39,7 @@ export class MachineController { activity: undefined, sessionStatuses: {}, sessionActivities: {}, + sendingPrompts: {}, workspaceActivities: {}, workspacesByProjectId: {}, workspaceDeletionRuns: {}, diff --git a/src/client/src/controllers/sessionController.test.ts b/src/client/src/controllers/sessionController.test.ts index 245654f..52c7224 100644 --- a/src/client/src/controllers/sessionController.test.ts +++ b/src/client/src/controllers/sessionController.test.ts @@ -142,9 +142,8 @@ describe("SessionController", () => { expect(state.selectedSession?.messageCount).toBe(3); }); - it("toggles the sending state around an inline attachment send and forwards attachments", async () => { + it("toggles the per-session 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" }]; @@ -164,15 +163,42 @@ describe("SessionController", () => { ); const send = controller.send("look", undefined, attachments, "inline"); - sendingDuringPrompt.push(state.isSendingPrompt); + const sendingDuringPrompt = state.sendingPrompts; resolvePrompt?.(); await send; - expect(sendingDuringPrompt).toEqual([true]); - expect(state.isSendingPrompt).toBe(false); + expect(sendingDuringPrompt).toEqual({ [oldSession.id]: true }); + expect(state.sendingPrompts).toEqual({}); expect(promptArgs).toEqual({ attachments }); }); + it("keeps the sending state scoped to the originating session when the user switches away", async () => { + let resolvePrompt: (() => void) | undefined; + let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, selectedSession: oldSession, sessions: [oldSession, replacementSession] }; + const attachments: PromptAttachment[] = [{ kind: "image", mimeType: "image/png", data: "QUJD", name: "shot.png" }]; + const api: typeof defaultApi = { + ...defaultApi, + prompt: () => new Promise<{ accepted: true }>((resolve) => { 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"); + // While the upload is in flight, deselecting must not clear the originating + // session's sending entry, and it must stay keyed to that session only. + controller.deselectSession(); + expect(state.sendingPrompts).toEqual({ [oldSession.id]: true }); + expect(state.sendingPrompts[replacementSession.id]).toBeUndefined(); + resolvePrompt?.(); + await send; + expect(state.sendingPrompts).toEqual({}); + }); + it("uploads to the workspace folder and rewrites the prompt for folder delivery", async () => { let savedCalledWith: PromptAttachment[] | undefined; let promptText: string | undefined; @@ -197,15 +223,15 @@ describe("SessionController", () => { expect(savedCalledWith).toEqual(attachments); expect(promptText).toBe("check this\n\n@.pi-web/paste/shot.png"); expect(promptAttachments).toBeUndefined(); - expect(state.isSendingPrompt).toBe(false); + expect(state.sendingPrompts).toEqual({}); }); 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 seen: Record[] = []; const api: typeof defaultApi = { ...defaultApi, - prompt: () => { seen.push(state.isSendingPrompt); return Promise.resolve({ accepted: true }); }, + prompt: () => { seen.push({ ...state.sendingPrompts }); return Promise.resolve({ accepted: true }); }, }; const controller = new SessionController( () => state, @@ -216,8 +242,8 @@ describe("SessionController", () => { ); await controller.send("hello"); - expect(seen).toEqual([false]); - expect(state.isSendingPrompt).toBe(false); + expect(seen).toEqual([{}]); + expect(state.sendingPrompts).toEqual({}); }); it("keeps live message count updates when a cached new session becomes persisted", async () => { diff --git a/src/client/src/controllers/sessionController.ts b/src/client/src/controllers/sessionController.ts index 554488b..81718d2 100644 --- a/src/client/src/controllers/sessionController.ts +++ b/src/client/src/controllers/sessionController.ts @@ -64,7 +64,11 @@ 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, isSendingPrompt: false, status: undefined, activity: undefined }); + // Note: sendingPrompts is intentionally NOT cleared here. Deselecting a + // session must not cancel the in-flight upload indicator of the session + // that is still sending; the per-session entry is cleared by send()'s + // finally block when the request settles. + this.setState({ selectedSession: undefined, messages: [], messagePageStart: 0, messagePageEnd: 0, messagePageTotal: 0, isLoadingEarlierMessages: false, isReceivingPartialStream: false, status: undefined, activity: undefined }); } deselectSession(options?: { forgetRememberedSelection?: boolean | undefined; updateUrl?: boolean | undefined }) { @@ -176,25 +180,38 @@ export class SessionController { 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 }); + // Capture the originating session/machine before any await so the request + // and its sending indicator stay bound to the right session even if the + // user navigates elsewhere mid-upload. + const sessionId = session.id; + const machineId = selectedMachineId(this.getState()); + // Surface a per-session optimistic sending state. 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.markSendingPrompt(sessionId, true); try { if (hasAttachments && delivery === "folder") { - const saved = await this.api.saveAttachments(session, attachments, selectedMachineId(this.getState())); + const saved = await this.api.saveAttachments(session, attachments, machineId); 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())); + await this.api.prompt(session, body, streamingBehavior, machineId); } else { - await this.api.prompt(session, text, streamingBehavior, selectedMachineId(this.getState()), attachments); + await this.api.prompt(session, text, streamingBehavior, machineId, attachments); } this.markCachedNewSessionPersisted(session); } catch (error) { this.setState({ error: String(error) }); } finally { - if (hasAttachments) this.setState({ isSendingPrompt: false }); + if (hasAttachments) this.markSendingPrompt(sessionId, false); + } + } + + private markSendingPrompt(sessionId: string, sending: boolean): void { + const current = this.getState().sendingPrompts; + if (sending) { + if (current[sessionId] !== true) this.setState({ sendingPrompts: { ...current, [sessionId]: true } }); + } else if (sessionId in current) { + this.setState({ sendingPrompts: omitKey(current, sessionId) }); } } @@ -634,7 +651,11 @@ export class SessionController { } function omitSessionActivity(activities: Record, sessionId: string): Record { - return Object.fromEntries(Object.entries(activities).filter(([id]) => id !== sessionId)); + return omitKey(activities, sessionId); +} + +function omitKey(record: Record, key: string): Record { + return Object.fromEntries(Object.entries(record).filter(([id]) => id !== key)); } function uniqueSessionsById(sessions: readonly SessionInfo[]): SessionInfo[] {