refactor: drive attachment sending state through the chat activity dock

Replace the composer-local "Sending…" indicator with a single, consistent
state surfaced by the existing chat activity dock. The session controller
now owns the full attachment send lifecycle (including the folder-mode
upload + reference rewrite) and toggles an isSendingPrompt flag around it,
which the chat dock renders as "Sending your message…" until the real
server activity supersedes it.

This covers the pre-receipt dead zone (upload, server-side image resize,
first-session open) with one indicator instead of two, and keeps plain
text sends fire-and-forget.
This commit is contained in:
Federico Jaramillo Martinez
2026-06-13 14:09:04 +02:00
parent 53b00c47f9
commit ecede3ac7c
8 changed files with 128 additions and 68 deletions
+2
View File
@@ -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,
+11 -1
View File
@@ -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`
<div class="activity-dock active" aria-live="polite">
<span class="dot"></span>
<span class="activity-text">Sending your message…</span>
</div>
`;
}
const state = this.activityState();
if (state === undefined) return null;
const active = state !== "idle" || this.activity?.phase === "active";
+4 -4
View File
@@ -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<void> {
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`<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} .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} .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(); }}></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.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>
<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}
+13 -51
View File
@@ -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<void>;
@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<void>;
@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`
<footer class=${shellMode ? "shell-mode" : ""} @paste=${(event: ClipboardEvent) => { void this.handlePaste(event); }} @dragover=${(event: DragEvent) => { this.handleDragOver(event); }} @drop=${(event: DragEvent) => { void this.handleDrop(event); }}>
<div class="editor-wrap">
<div class=${`markdown-editor${this.disabled ? " markdown-editor-disabled" : ""}`} aria-label="Message pi" aria-disabled=${this.disabled ? "true" : "false"}></div>
${shellMode ? html`<div class="mode-hint">Shell command${inputMode.excludeFromContext ? " · excluded from context" : ""}</div>` : null}
${this.isCompacting && !shellMode ? html`<div class="mode-hint">Compacting history · message will be queued</div>` : null}
${uploading ? html`<div class="mode-hint sending-hint" role="status">${this.isSavingAttachments ? "Saving your files…" : "Sending your files…"}</div>` : null}
${this.renderAttachments()}
<autocomplete-menu .items=${this.completions} .selectedIndex=${this.selectedIndex} .onPick=${(item: CompletionItem) => { this.pick(item); }}></autocomplete-menu>
</div>
@@ -104,8 +99,8 @@ export class PromptEditor extends LitElement {
${this.renderCompactStatus()}
<input class="attachment-input" type="file" accept="image/png,image/jpeg,image/gif,image/webp" multiple hidden @change=${(event: Event) => { void this.handleFileInput(event); }} />
<button class="attach-button" ?disabled=${busy} title="Attach images" @click=${() => { this.attachmentInput?.click(); }}>Attach</button>
<button ?disabled=${busy} title=${queuesInput ? "Queue until the current activity finishes" : "Send message"} @click=${() => { void this.send("followUp"); }}>${sendLabel}</button>
${this.canSteer && !this.isCompacting ? html`<button ?disabled=${busy} title="Steer the current response before the next model call" @click=${() => { void this.send("steer"); }}>Steer</button>` : null}
<button ?disabled=${busy} title=${queuesInput ? "Queue until the current activity finishes" : "Send message"} @click=${() => { this.send("followUp"); }}>${queuesInput ? "Queue" : "Send"}</button>
${this.canSteer && !this.isCompacting ? html`<button ?disabled=${busy} title="Steer the current response before the next model call" @click=${() => { this.send("steer"); }}>Steer</button>` : null}
<button ?disabled=${this.disabled || !this.canStop} title=${this.canStop ? "Stop current work and clear queued messages" : "Nothing running"} @click=${() => this.onStop?.()}>Stop</button>
</div>
</footer>
@@ -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() {
-1
View File
@@ -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; }
@@ -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\[email protected]/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;
@@ -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;