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:
@@ -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