Archived
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:
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
Reference in New Issue
Block a user