fix: scope the attachment sending indicator per session

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<sessionId, true> 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.
This commit is contained in:
Federico Jaramillo Martinez
2026-06-13 21:09:31 +02:00
parent 970c0bf1d2
commit 886393a31e
8 changed files with 81 additions and 27 deletions
+3 -2
View File
@@ -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<string, true>;
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,
+3 -2
View File
@@ -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`<div class="error">${state.error}</div>` : null}
<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} .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())}></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} .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(); }}></prompt-editor>
<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} .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())}></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} .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(); }}></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}
+3 -1
View File
@@ -27,6 +27,7 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
@property({ attribute: false }) sessions: SessionInfo[] = [];
@property({ attribute: false }) statuses: Record<string, SessionStatus> = {};
@property({ attribute: false }) activities: Record<string, SessionActivity> = {};
@property({ attribute: false }) sending: Record<string, true> = {};
@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`
@@ -28,6 +28,7 @@ export class AppNavigationPanel extends LitElement {
@property({ attribute: false }) workspaceActivities: Record<string, WorkspaceActivity> = {};
@property({ attribute: false }) sessionActivities: Record<string, SessionActivity> = {};
@property({ attribute: false }) sessionStatuses: Record<string, SessionStatus> = {};
@property({ attribute: false }) sendingPrompts: Record<string, true> = {};
@property({ attribute: false }) workspacesByProjectId: Record<string, Workspace[]> = {};
@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}
@@ -39,6 +39,7 @@ export class MachineController {
activity: undefined,
sessionStatuses: {},
sessionActivities: {},
sendingPrompts: {},
workspaceActivities: {},
workspacesByProjectId: {},
workspaceDeletionRuns: {},
@@ -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\[email protected]/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<string, true>[] = [];
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 () => {
+32 -11
View File
@@ -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<string, SessionActivity>, sessionId: string): Record<string, SessionActivity> {
return Object.fromEntries(Object.entries(activities).filter(([id]) => id !== sessionId));
return omitKey(activities, sessionId);
}
function omitKey<T>(record: Record<string, T>, key: string): Record<string, T> {
return Object.fromEntries(Object.entries(record).filter(([id]) => id !== key));
}
function uniqueSessionsById(sessions: readonly SessionInfo[]): SessionInfo[] {