Archived
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:
@@ -2,4 +2,4 @@
|
||||
"@jmfederico/pi-web": patch
|
||||
---
|
||||
|
||||
Show a sending indicator in the chat composer 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 Send button now shows "Sending…" and a "Sending your files…" (or "Saving your files…" for folder mode) hint until the message lands, and the composer is disabled while in flight.
|
||||
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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user