fix: keep chat prompt input stable during streaming

Coalesce session status/activity updates into one render per animation
frame instead of one per token, ignore prompt-editor status changes that
do not affect what it displays, and stop per-keystroke draft state from
re-rendering the surrounding template. This prevents streaming-driven
re-renders from interrupting in-progress touch gestures such as the iOS
long-press paste/edit callout.
This commit is contained in:
Federico Jaramillo Martinez
2026-06-27 08:12:01 +02:00
parent 041423a03b
commit 2009e6a883
7 changed files with 287 additions and 36 deletions
@@ -0,0 +1,5 @@
---
"@jmfederico/pi-web": patch
---
Keep the chat prompt input stable during streaming so mobile touch gestures (such as the iOS long-press paste/edit callout) are no longer interrupted. Session status and activity updates are now coalesced into a single render per animation frame instead of one per token, the prompt editor ignores status changes that do not affect what it displays, and per-keystroke draft state no longer triggers surrounding re-renders.
+20 -1
View File
@@ -1830,6 +1830,25 @@ export class PiWebApp extends LitElement {
void this.sessions.send(text, streamingBehavior, attachments, delivery);
}
// Stable handler identities for <prompt-editor>. Inlined arrow closures would
// be a fresh reference on every render, forcing Lit to re-commit the bindings
// each time the app re-renders; bound class fields keep them constant.
private readonly handleSendPrompt = (text: string, streamingBehavior?: "steer" | "followUp", attachments?: import("../api").PromptAttachment[], delivery?: import("../../../shared/apiTypes").PromptAttachmentDelivery): void => {
this.sendPrompt(text, streamingBehavior, attachments, delivery);
};
private readonly handleStopActiveWork = (): void => {
void this.sessions.stopActiveWork();
};
private readonly handleSelectModel = (): void => {
void this.openModelDialog();
};
private readonly handleSelectThinking = (): void => {
void this.openThinkingDialog();
};
private renderContextBar() {
if (!this.appShell.isMobileNavigationLayout) return null;
return html`
@@ -1889,7 +1908,7 @@ export class PiWebApp extends LitElement {
<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.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)} .projectId=${state.selectedWorkspace?.projectId} .workspaceId=${state.selectedWorkspace?.id} .workspaceScopedFileSuggestions=${this.supportsWorkspaceFileSuggestions()} .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} .availableThinkingLevels=${state.availableThinkingLevels} .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>
<prompt-editor .sessionId=${state.selectedSession.id} .cwd=${state.selectedWorkspace?.path} .machineId=${selectedMachineId(state)} .projectId=${state.selectedWorkspace?.projectId} .workspaceId=${state.selectedWorkspace?.id} .workspaceScopedFileSuggestions=${this.supportsWorkspaceFileSuggestions()} .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} .availableThinkingLevels=${state.availableThinkingLevels} .sending=${state.sendingPrompts[state.selectedSession.id] === true} .onSend=${this.handleSendPrompt} .onStop=${this.handleStopActiveWork} .onSelectModel=${this.handleSelectModel} .onSelectThinking=${this.handleSelectThinking}></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}
+44 -6
View File
@@ -8,7 +8,7 @@ import { customElement, property, query, state } from "lit/decorators.js";
import { api, type FileSuggestion, type PromptAttachment, type SessionStatus, type SlashCommand } from "../api";
import type { PromptAttachmentDelivery } from "../../../shared/apiTypes";
import { capturePromptAttachments, effectivePromptAttachmentDelivery, isInlinePromptAttachment, promptAttachmentsCanUseInlineDelivery, type CapturedAttachment } from "../promptAttachmentCapture";
import { inputModeForDraft } from "../inputModes";
import { inputModeForDraft, inputModesEqual, type InputMode } from "../inputModes";
import { machineSessionKey } from "../machineKeys";
import { detectPromptCompletionTrigger, fileCompletionInsertText, type PromptCompletionTrigger } from "../promptCompletions";
import { clearDraft, loadDraft, saveDraft } from "../promptDraftStorage";
@@ -42,7 +42,14 @@ export class PromptEditor extends LitElement {
@property({ attribute: false }) availableThinkingLevels: readonly string[] = [];
@query(".markdown-editor") private editorHost?: HTMLDivElement;
@query(".attachment-input") private attachmentInput?: HTMLInputElement;
@state() private draft = "";
// `draft` is the live document text but is intentionally NOT reactive: it
// changes on every keystroke and the visible text is owned by CodeMirror, not
// by Lit's render. Re-rendering the surrounding template on each keystroke is
// wasted work and, on iOS, can interrupt an in-progress touch gesture (the
// long-press edit/paste callout). Only `currentInputMode` (shell vs. normal)
// is reactive, since that is the only draft-derived value the template shows.
private draft = "";
@state() private currentInputMode: InputMode = { kind: "normal" };
@state() private completions: CompletionItem[] = [];
@state() private selectedIndex = 0;
@state() private attachments: PendingAttachment[] = [];
@@ -64,17 +71,29 @@ export class PromptEditor extends LitElement {
if (previousKey !== undefined) saveDraft(previousKey, this.draft);
const currentKey = draftStorageKey(this.machineId, this.sessionId);
this.draft = currentKey !== undefined ? loadDraft(currentKey) : "";
this.currentInputMode = inputModeForDraft(this.draft);
this.completions = [];
this.selectedIndex = 0;
}
protected override shouldUpdate(changed: PropertyValues<this>): boolean {
// Status updates churn once per token during streaming and hand us a fresh
// object reference each time. When nothing else changed, only re-render if a
// status field the template actually displays differs, so streaming does not
// disturb the editor DOM (and any in-progress touch gesture survives).
if (changed.has("status") && changed.size === 1) {
return !sessionStatusRenderEqual(changed.get("status"), this.status);
}
return true;
}
override firstUpdated(): void {
this.createEditor();
}
protected override updated(changed: PropertyValues) {
if (changed.has("disabled")) this.updateEditorDisabledState();
if (changed.has("draft") || changed.has("sessionId") || changed.has("machineId")) this.syncEditorDoc();
if (changed.has("sessionId") || changed.has("machineId")) this.syncEditorDoc();
}
override disconnectedCallback(): void {
@@ -84,8 +103,8 @@ export class PromptEditor extends LitElement {
}
override render() {
const inputMode = inputModeForDraft(this.draft);
const shellMode = inputMode.kind === "shell";
const shellInputMode = this.currentInputMode.kind === "shell" ? this.currentInputMode : undefined;
const shellMode = shellInputMode !== undefined;
const queuesInput = this.canSteer || this.isCompacting;
const busy = this.disabled || this.sending;
return html`
@@ -94,7 +113,7 @@ export class PromptEditor extends LitElement {
<div class=${`markdown-editor${this.disabled ? " markdown-editor-disabled" : ""}`} aria-label="Message pi" aria-disabled=${this.disabled ? "true" : "false"}></div>
<input class="attachment-input" type="file" multiple hidden @change=${(event: Event) => { void this.handleFileInput(event); }} />
<button class="editor-attach icon-button" ?disabled=${busy} title="Attach files" aria-label="Attach files" @click=${() => { this.attachmentInput?.click(); }}>${renderAttachIcon()}</button>
${shellMode ? html`<div class="mode-hint">Shell command${inputMode.excludeFromContext ? " · excluded from context" : ""}</div>` : null}
${shellMode ? html`<div class="mode-hint">Shell command${shellInputMode.excludeFromContext ? " · excluded from context" : ""}</div>` : null}
${this.isCompacting && !shellMode ? html`<div class="mode-hint">Compacting history · message will be queued</div>` : null}
${this.renderAttachments()}
<autocomplete-menu .items=${this.completions} .selectedIndex=${this.selectedIndex} .onPick=${(item: CompletionItem) => { this.pick(item); }}></autocomplete-menu>
@@ -288,6 +307,8 @@ export class PromptEditor extends LitElement {
this.draft = value;
const key = draftStorageKey(this.machineId, this.sessionId);
if (key !== undefined) saveDraft(key, this.draft);
const nextInputMode = inputModeForDraft(this.draft);
if (!inputModesEqual(nextInputMode, this.currentInputMode)) this.currentInputMode = nextInputMode;
void this.refreshCompletions();
}
@@ -432,16 +453,33 @@ export class PromptEditor extends LitElement {
private resetComposer() {
this.draft = "";
this.currentInputMode = { kind: "normal" };
const key = draftStorageKey(this.machineId, this.sessionId);
if (key !== undefined) clearDraft(key);
this.completions = [];
this.attachments = [];
this.attachmentError = undefined;
// `draft` is not reactive, so the cleared text will not flow to CodeMirror
// via `updated()`; push it to the editor document explicitly.
this.syncEditorDoc();
}
static override styles = promptEditorStyles;
}
// The only `status` fields the template reads directly are the model identity
// and thinking level (shown in renderCompactStatus). Everything else the editor
// cares about (canSteer/canStop/isCompacting/sending) is passed as a separate
// property that Lit already diffs by value. Comparing just these fields lets us
// ignore the per-token status churn that does not change anything on screen.
function sessionStatusRenderEqual(a: SessionStatus | undefined, b: SessionStatus | undefined): boolean {
if (a === b) return true;
if (a === undefined || b === undefined) return false;
return a.model?.id === b.model?.id
&& a.model?.provider === b.model?.provider
&& a.thinkingLevel === b.thinkingLevel;
}
function draftStorageKey(machineId: unknown, sessionId: unknown): string | undefined {
if (typeof machineId !== "string" || machineId === "") return undefined;
if (typeof sessionId !== "string" || sessionId === "") return undefined;
@@ -1,5 +1,6 @@
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { api as defaultApi, type MessagePage, type PromptAttachment, type SessionActivity, type SessionInfo, type SessionRef, type SessionStatus, type Workspace } from "../api";
import type { SessionUiEvent } from "../sessionSocket";
import { isCachedNewSessionInfo, loadCachedNewSessions, markCachedNewSessionInfo, rememberCachedNewSession } from "../cachedNewSessions";
import { initialAppState, type AppState } from "../appState";
import { machineSessionKey } from "../machineKeys";
@@ -52,6 +53,28 @@ class FakeSocket implements SessionEventSocket {
}
}
class EmitSocket implements SessionEventSocket {
readonly connectedSessionIds: string[] = [];
private handler: ((event: SessionUiEvent) => void) | undefined;
connect(session: SessionRef, onEvent: (event: SessionUiEvent) => void): void {
this.connectedSessionIds.push(session.id);
this.handler = onEvent;
}
setHandler(onEvent: (event: SessionUiEvent) => void): void {
this.handler = onEvent;
}
emit(event: SessionUiEvent): void {
this.handler?.(event);
}
close(): void {
this.handler = undefined;
}
}
const workspace: Workspace = {
id: "workspace-1",
projectId: "project-1",
@@ -93,11 +116,116 @@ function status(sessionId: string): SessionStatus {
};
}
const framesById = new Map<number, () => void>();
let nextFrameId = 1;
// The controller coalesces status/activity/transcript updates behind
// requestAnimationFrame. The node test environment has no rAF, so install a
// controllable one: callbacks are queued and only run when a test drives a
// frame, mirroring how the browser defers them until paint.
beforeEach(() => {
framesById.clear();
nextFrameId = 1;
vi.stubGlobal("requestAnimationFrame", (callback: () => void) => {
const id = nextFrameId++;
framesById.set(id, callback);
return id;
});
vi.stubGlobal("cancelAnimationFrame", (id: number) => { framesById.delete(id); });
});
afterEach(() => {
vi.unstubAllGlobals();
});
function runPendingAnimationFrames(): void {
const frames = Array.from(framesById.values());
framesById.clear();
for (const frame of frames) frame();
}
describe("SessionController", () => {
afterEach(() => {
Object.defineProperty(globalThis, "localStorage", { value: undefined, configurable: true });
});
it("coalesces rapid status updates into a single state write per frame", () => {
const setStateCalls: Partial<AppState>[] = [];
let state: AppState = { ...initialAppState(), selectedSession: oldSession, sessions: [oldSession] };
const controller = new SessionController(
() => state,
(patch) => { setStateCalls.push(patch); state = { ...state, ...patch }; },
() => undefined,
undefined,
{ socket: new FakeSocket() },
);
controller.applyGlobalEvent({ type: "status.update", status: { ...status(oldSession.id), isStreaming: true, messageCount: 1 } });
controller.applyGlobalEvent({ type: "status.update", status: { ...status(oldSession.id), isStreaming: true, messageCount: 2 } });
controller.applyGlobalEvent({ type: "status.update", status: { ...status(oldSession.id), isStreaming: true, messageCount: 3 } });
// Nothing applies until the frame is flushed; last-write-wins per session.
expect(setStateCalls).toHaveLength(0);
expect(state.sessionStatuses[oldSession.id]).toBeUndefined();
runPendingAnimationFrames();
expect(setStateCalls).toHaveLength(1);
expect(state.sessionStatuses[oldSession.id]).toMatchObject({ sessionId: oldSession.id, messageCount: 3 });
expect(state.status?.messageCount).toBe(3);
});
it("applies the latest activity per session on flush", () => {
const setStateCalls: Partial<AppState>[] = [];
let state: AppState = { ...initialAppState(), selectedSession: oldSession, sessions: [oldSession] };
const controller = new SessionController(
() => state,
(patch) => { setStateCalls.push(patch); state = { ...state, ...patch }; },
() => undefined,
undefined,
{ socket: new FakeSocket() },
);
controller.applyGlobalEvent({ type: "activity.update", activity: { sessionId: oldSession.id, phase: "active", label: "running tool", at: "t1" } });
controller.applyGlobalEvent({ type: "activity.update", activity: { sessionId: oldSession.id, phase: "idle", label: "idle", at: "t2" } });
expect(setStateCalls).toHaveLength(0);
controller.flushPendingUpdates();
expect(state.sessionActivities[oldSession.id]).toMatchObject({ phase: "idle", label: "idle" });
expect(state.activity?.phase).toBe("idle");
});
it("coalesces status updates delivered over the per-session socket until the frame is flushed", async () => {
const socket = new EmitSocket();
let state: AppState = { ...initialAppState(), selectedWorkspace: workspace, selectedSession: oldSession, sessions: [oldSession] };
const api: typeof defaultApi = {
...defaultApi,
messages: () => Promise.resolve(emptyPage),
status: () => Promise.resolve(status(oldSession.id)),
};
const controller = new SessionController(
() => state,
(patch) => { state = { ...state, ...patch }; },
() => undefined,
undefined,
{ api, socket },
);
await controller.selectSession(oldSession, { updateUrl: false });
socket.emit({ type: "status.update", status: { ...status(oldSession.id), isStreaming: true, messageCount: 7 } });
socket.emit({ type: "status.update", status: { ...status(oldSession.id), isStreaming: true, messageCount: 8 } });
// Buffered, not applied synchronously.
expect(state.sessionStatuses[oldSession.id]?.messageCount).toBeUndefined();
controller.flushPendingUpdates();
expect(state.sessionStatuses[oldSession.id]?.messageCount).toBe(8);
expect(state.status?.messageCount).toBe(8);
});
it("clears stale active activity when an idle status arrives", () => {
const activeActivity: SessionActivity = { sessionId: oldSession.id, phase: "active", label: "running tool", at: "2026-05-15T00:00:00.000Z" };
let state: AppState = {
@@ -116,6 +244,7 @@ describe("SessionController", () => {
);
controller.applyGlobalEvent({ type: "status.update", status: status(oldSession.id) });
controller.flushPendingUpdates();
expect(state.activity).toBeUndefined();
expect(state.sessionActivities[oldSession.id]).toBeUndefined();
@@ -137,6 +266,7 @@ describe("SessionController", () => {
);
controller.applyGlobalEvent({ type: "status.update", status: { ...status(oldSession.id), messageCount: 3 } });
controller.flushPendingUpdates();
expect(state.sessions[0]?.messageCount).toBe(3);
expect(state.selectedSession?.messageCount).toBe(3);
@@ -356,6 +486,7 @@ describe("SessionController", () => {
const send = controller.send("hello");
controller.applyGlobalEvent({ type: "status.update", status: { ...status(oldSession.id), messageCount: 1 } });
controller.flushPendingUpdates();
resolvePrompt?.();
await send;
+72 -27
View File
@@ -34,7 +34,9 @@ export class SessionController {
private selectionSeq = 0;
private catchupStreamSessionId: string | undefined;
private pendingTranscriptEvents: SessionUiEvent[] = [];
private pendingTranscriptFrame: number | undefined;
private pendingStatusBySession = new Map<string, SessionStatus>();
private pendingActivityBySession = new Map<string, SessionActivity>();
private pendingFrame: number | undefined;
constructor(
private readonly getState: GetState,
@@ -49,22 +51,22 @@ export class SessionController {
}
applyGlobalEvent(event: GlobalSessionEvent): void {
if (event.type === "status.update") this.applyStatus(event.status);
else if (event.type === "activity.update") this.applyActivity(event.activity);
if (event.type === "status.update") this.queueStatusUpdate(event.status);
else if (event.type === "activity.update") this.queueActivityUpdate(event.activity);
else if (event.type === "session.created") this.applyCreatedSession(event.session);
else this.applySessionName(event.sessionId, event.name);
}
dispose() {
this.socket.close();
this.clearPendingTranscriptEvents();
this.clearPendingUpdates();
}
clearActiveSession() {
this.selectionSeq += 1;
this.socket.close();
this.catchupStreamSessionId = undefined;
this.clearPendingTranscriptEvents();
this.clearPendingUpdates();
// 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
@@ -113,7 +115,7 @@ export class SessionController {
const seq = ++this.selectionSeq;
this.socket.close();
this.catchupStreamSessionId = undefined;
this.clearPendingTranscriptEvents();
this.clearPendingUpdates();
const transcriptKey = this.sessionCacheKey(session.id);
const cached = this.transcripts.cachedView(transcriptKey);
this.setState({
@@ -563,7 +565,7 @@ export class SessionController {
const session = this.getState().selectedSession;
if (sessionId === undefined || session?.id !== sessionId || session.archived === true) return;
try {
this.flushPendingTranscriptEvents();
this.flushPendingUpdates();
const [page, status] = await Promise.all([this.api.messages(session, { limit: MESSAGE_PAGE_SIZE }, selectedMachineId(this.getState())), this.api.status(session, selectedMachineId(this.getState()))]);
if (this.getState().selectedSession?.id !== sessionId) return;
const history = this.transcripts.mergeHistory(this.sessionCacheKey(sessionId), page);
@@ -694,19 +696,29 @@ export class SessionController {
if (isTranscriptEvent(event)) return;
}
// Status and activity arrive once per token (the server republishes them on
// every transcript event). Buffer them alongside high-frequency transcript
// deltas so the host component renders at most once per animation frame
// instead of once per token. Coalescing these here is what keeps the prompt
// editor's DOM stable during streaming, so in-progress touch gestures (e.g.
// the iOS long-press edit/paste callout) are not interrupted by a re-render.
if (event.type === "status.update") {
this.queueStatusUpdate(event.status);
return;
}
if (event.type === "activity.update") {
this.queueActivityUpdate(event.activity);
return;
}
if (isHighFrequencyTranscriptEvent(event)) {
this.queueTranscriptEvent(event);
return;
}
this.flushPendingTranscriptEvents();
this.flushPendingUpdates();
const transcript = this.transcripts.applyLiveEvent(this.getState().messages, event);
if (transcript) {
this.setState({ messages: transcript });
} else if (event.type === "status.update") {
this.applyStatus(event.status);
} else if (event.type === "activity.update") {
this.applyActivity(event.activity);
} else if (event.type === "session.name") {
this.applySessionName(event.sessionId, event.name);
}
@@ -714,27 +726,60 @@ export class SessionController {
private queueTranscriptEvent(event: SessionUiEvent): void {
this.pendingTranscriptEvents.push(event);
if (this.pendingTranscriptFrame !== undefined) return;
this.pendingTranscriptFrame = requestAnimationFrame(() => {
this.pendingTranscriptFrame = undefined;
this.flushPendingTranscriptEvents();
this.schedulePendingFlush();
}
private queueStatusUpdate(status: SessionStatus): void {
this.pendingStatusBySession.set(status.sessionId, status);
this.schedulePendingFlush();
}
private queueActivityUpdate(activity: SessionActivity): void {
this.pendingActivityBySession.set(activity.sessionId, activity);
this.schedulePendingFlush();
}
private schedulePendingFlush(): void {
if (this.pendingFrame !== undefined) return;
this.pendingFrame = requestAnimationFrame(() => {
this.pendingFrame = undefined;
this.flushPendingUpdates();
});
}
private flushPendingTranscriptEvents(): void {
if (this.pendingTranscriptEvents.length === 0) return;
const events = this.pendingTranscriptEvents;
this.pendingTranscriptEvents = [];
let messages = this.getState().messages;
for (const event of events) messages = this.transcripts.applyLiveEvent(messages, event) ?? messages;
if (messages !== this.getState().messages) this.setState({ messages });
// Apply buffered transcript deltas, activity, and status in one task. Activity
// is applied before status to mirror the server's publish order, so an idle
// status can clear the now-stale active activity it supersedes. Status and
// activity are last-write-wins per session, so iterating the maps applies only
// the latest buffered value per session. These writes run in a single task, so
// Lit batches them into one render.
flushPendingUpdates(): void {
if (this.pendingTranscriptEvents.length > 0) {
const events = this.pendingTranscriptEvents;
this.pendingTranscriptEvents = [];
let messages = this.getState().messages;
for (const event of events) messages = this.transcripts.applyLiveEvent(messages, event) ?? messages;
if (messages !== this.getState().messages) this.setState({ messages });
}
if (this.pendingActivityBySession.size > 0) {
const activities = Array.from(this.pendingActivityBySession.values());
this.pendingActivityBySession.clear();
for (const activity of activities) this.applyActivity(activity);
}
if (this.pendingStatusBySession.size > 0) {
const statuses = Array.from(this.pendingStatusBySession.values());
this.pendingStatusBySession.clear();
for (const status of statuses) this.applyStatus(status);
}
}
private clearPendingTranscriptEvents(): void {
private clearPendingUpdates(): void {
this.pendingTranscriptEvents = [];
if (this.pendingTranscriptFrame === undefined) return;
cancelAnimationFrame(this.pendingTranscriptFrame);
this.pendingTranscriptFrame = undefined;
this.pendingStatusBySession.clear();
this.pendingActivityBySession.clear();
if (this.pendingFrame === undefined) return;
cancelAnimationFrame(this.pendingFrame);
this.pendingFrame = undefined;
}
// Stream catch-up is a single mode with two coupled facets that must never
+8 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { inputModeForDraft, isShellInput } from "./inputModes";
import { inputModeForDraft, inputModesEqual, isShellInput } from "./inputModes";
describe("inputModeForDraft", () => {
it("detects shell input and context-excluded shell input after leading whitespace", () => {
@@ -14,6 +14,13 @@ describe("inputModeForDraft", () => {
expect(inputModeForDraft("please mention/path")).toEqual({ kind: "normal" });
});
it("treats modes as equal only when kind and shell context-exclusion match", () => {
expect(inputModesEqual({ kind: "normal" }, { kind: "normal" })).toBe(true);
expect(inputModesEqual({ kind: "normal" }, { kind: "command" })).toBe(false);
expect(inputModesEqual({ kind: "shell", excludeFromContext: false }, { kind: "shell", excludeFromContext: false })).toBe(true);
expect(inputModesEqual({ kind: "shell", excludeFromContext: false }, { kind: "shell", excludeFromContext: true })).toBe(false);
});
it("detects file completion contexts", () => {
expect(inputModeForDraft("open @src/main.ts")).toEqual({ kind: "file" });
expect(inputModeForDraft("open @ ")).toEqual({ kind: "file" });
+6
View File
@@ -19,6 +19,12 @@ export function isShellInput(text: string): boolean {
return inputModeForDraft(text).kind === "shell";
}
export function inputModesEqual(a: InputMode, b: InputMode): boolean {
if (a.kind !== b.kind) return false;
if (a.kind === "shell" && b.kind === "shell") return a.excludeFromContext === b.excludeFromContext;
return true;
}
function currentToken(draft: string): string {
const tokenStart = Math.max(draft.lastIndexOf(" "), draft.lastIndexOf("\n")) + 1;
return draft.slice(tokenStart);