fix(ui): keep composer sends on one stable bubble through the history handoff and add a subtle entry animation (#112567)

* fix(ui): keep composer sends on one stable bubble through the history handoff and add a subtle entry animation

* fix(ui): keep userTurnSendIdentity module-local
This commit is contained in:
Peter Steinberger
2026-07-22 13:02:49 -07:00
committed by GitHub
parent 988e640c7a
commit 6bf03cd674
6 changed files with 401 additions and 3 deletions
@@ -0,0 +1,297 @@
// Control UI E2E tests cover the pending-send bubble handoff to authoritative history.
import { chromium, type Browser, type Page } from "playwright";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import {
canRunPlaywrightChromium,
installMockGateway,
resolvePlaywrightChromiumExecutablePath,
startControlUiE2eServer,
type ControlUiE2eServer,
type MockGatewayControls,
} from "../test-helpers/control-ui-e2e.ts";
const chromiumExecutablePath = resolvePlaywrightChromiumExecutablePath(chromium.executablePath());
const chromiumAvailable = canRunPlaywrightChromium(chromiumExecutablePath);
const allowMissingChromium = process.env.OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM === "1";
const describeControlUiE2e = chromiumAvailable || !allowMissingChromium ? describe : describe.skip;
let browser: Browser;
let page: Page | undefined;
let server: ControlUiE2eServer | undefined;
type FrameSample = {
t: number;
present: boolean;
rowKeys: string[];
};
type SamplerWindow = Window & {
openclawSendFrameSamples?: FrameSample[];
openclawSendFrameSamplerStop?: () => void;
};
const PROBE_TEXT = "Flicker probe message 4242";
const USER_ECHO_ENTRY_ID = "pending-handoff-user-echo";
async function startFrameSampler(currentPage: Page): Promise<void> {
await currentPage.evaluate((probeText) => {
const win = window as SamplerWindow;
const frames: FrameSample[] = [];
win.openclawSendFrameSamples = frames;
let running = true;
win.openclawSendFrameSamplerStop = () => {
running = false;
};
const sample = () => {
if (!running) {
return;
}
const rows = [...document.querySelectorAll<HTMLElement>("[data-virtual-row-key]")].filter(
(row) => (row.textContent ?? "").includes(probeText),
);
frames.push({
t: performance.now(),
present: rows.length > 0,
rowKeys: rows.map((row) => row.dataset.virtualRowKey ?? ""),
});
requestAnimationFrame(sample);
};
requestAnimationFrame(sample);
}, PROBE_TEXT);
}
async function stopFrameSampler(currentPage: Page): Promise<FrameSample[]> {
return currentPage.evaluate(() => {
const win = window as SamplerWindow;
win.openclawSendFrameSamplerStop?.();
return win.openclawSendFrameSamples ?? [];
});
}
/** Presence gaps (frames where the probe text vanished after first paint) and
* the distinct row keys the probe bubble rendered under, in order. */
function analyzeFrameSamples(frames: FrameSample[]): { gapFrames: number; keyTimeline: string[] } {
const firstVisible = frames.findIndex((frame) => frame.present);
expect(firstVisible).toBeGreaterThanOrEqual(0);
// Counting through the final sample also catches a permanent disappearance.
expect(frames[frames.length - 1]?.present).toBe(true);
let gapFrames = 0;
for (let index = firstVisible; index < frames.length; index++) {
if (frames[index]?.present === false) {
gapFrames += 1;
}
}
const keyTimeline: string[] = [];
for (const frame of frames) {
for (const key of frame.rowKeys) {
if (keyTimeline[keyTimeline.length - 1] !== key) {
keyTimeline.push(key);
}
}
}
return { gapFrames, keyTimeline };
}
const BASE_HISTORY = [
{
content: [{ text: "Ready.", type: "text" }],
role: "assistant",
timestamp: Date.now() - 5_000,
__openclaw: { seq: 1 },
},
];
async function openChatAndSubmitProbe(
currentPage: Page,
gateway: MockGatewayControls,
opts?: { deferSend?: boolean },
): Promise<string> {
await currentPage.goto(`${server?.baseUrl ?? ""}chat`);
await currentPage.getByText("Ready.").waitFor({ timeout: 10_000 });
await gateway.waitForRequest("sessions.list");
if (opts?.deferSend) {
await gateway.deferNext("chat.send");
}
await startFrameSampler(currentPage);
await currentPage.locator(".agent-chat__input textarea").fill(PROBE_TEXT);
await currentPage.locator(".agent-chat__input textarea").press("Enter");
const send = await gateway.waitForRequest("chat.send");
const runId = (send.params as { idempotencyKey?: string }).idempotencyKey ?? "";
expect(runId).toBeTruthy();
await currentPage
.locator("[data-virtual-row-key]")
.getByText(PROBE_TEXT, { exact: true })
.waitFor({ timeout: 10_000 });
return runId;
}
async function finishRunAndSettle(
currentPage: Page,
gateway: MockGatewayControls,
runId: string,
userEcho: Record<string, unknown>,
): Promise<FrameSample[]> {
await gateway.setHistoryMessages([
...BASE_HISTORY,
userEcho,
{
content: [{ text: "Run complete.", type: "text" }],
role: "assistant",
timestamp: Date.now() + 1,
__openclaw: { seq: 3 },
},
]);
// The terminal reconciliation must re-read history; baseline before the
// final so either trigger (final event or terminal session row) counts.
const historyRequestsBeforeTerminal = (await gateway.getRequests("chat.history")).length;
const finalMessage = {
content: [{ text: "Run complete.", type: "text" }],
role: "assistant",
timestamp: Date.now() + 1,
__openclaw: { seq: 3 },
};
await gateway.emitChatFinal({ runId, text: "Run complete." });
await currentPage
.locator(".chat-bubble")
.getByText("Run complete.", { exact: true })
.waitFor({ timeout: 10_000 });
// The Gateway persists the assistant turn and publishes it with a terminal
// session row; this is what triggers the authoritative history reload.
await gateway.emitGatewayEvent("session.message", {
activeRunIds: [],
clientRunId: runId,
hasActiveRun: false,
message: finalMessage,
messageId: "pending-handoff-final-1",
messageSeq: 3,
session: {
activeRunIds: [],
hasActiveRun: false,
key: "main",
kind: "direct",
status: "done",
updatedAt: Date.now(),
},
sessionKey: "main",
});
// The handoff must complete: history is re-read and the probe bubble becomes
// the authoritative copy (its data-entry-id comes only from loaded history,
// never from the pending queue projection).
await expect
.poll(async () => (await gateway.getRequests("chat.history")).length, { timeout: 10_000 })
.toBeGreaterThan(historyRequestsBeforeTerminal);
await expect
.poll(
() => currentPage.locator(`.chat-bubble[data-entry-id="${USER_ECHO_ENTRY_ID}"]`).count(),
{
timeout: 10_000,
},
)
.toBe(1);
// Let a few more frames elapse so trailing samples cover the settled state.
await currentPage.waitForTimeout(500);
return stopFrameSampler(currentPage);
}
describeControlUiE2e("Control UI chat send pending handoff", () => {
beforeAll(async () => {
browser = await chromium.launch({ executablePath: chromiumExecutablePath });
try {
server = await startControlUiE2eServer();
} catch (error) {
await browser.close();
throw error;
}
});
afterEach(async () => {
await page
?.context()
.close()
.catch(() => {});
page = undefined;
});
afterAll(async () => {
await browser?.close().catch(() => {});
await server?.close();
});
it("keeps the submitted user turn visible through run completion and history reload", async () => {
const context = await browser.newContext({ viewport: { height: 800, width: 1200 } });
const currentPage = await context.newPage();
page = currentPage;
const gateway = await installMockGateway(currentPage, { historyMessages: BASE_HISTORY });
const runId = await openChatAndSubmitProbe(currentPage, gateway);
// A locally submitted turn plays the composer entry animation exactly once.
expect(await currentPage.locator(".chat-bubble--user-turn-enter").count()).toBe(1);
const frames = await finishRunAndSettle(currentPage, gateway, runId, {
content: [{ text: PROBE_TEXT, type: "text" }],
role: "user",
timestamp: Date.now(),
__openclaw: { id: USER_ECHO_ENTRY_ID, idempotencyKey: runId, seq: 2 },
});
const { gapFrames, keyTimeline } = analyzeFrameSamples(frames);
// The submitted text must never disappear once visible.
expect(gapFrames).toBe(0);
// The bubble keeps one identity through the pending -> history handoff, so
// the DOM node is never remounted (no animation replay, no layout jump).
expect(new Set(keyTimeline).size).toBe(1);
});
it("keeps the submitted user turn visible when the session echo lands before the send ack", async () => {
const context = await browser.newContext({ viewport: { height: 800, width: 1200 } });
const currentPage = await context.newPage();
page = currentPage;
const gateway = await installMockGateway(currentPage, { historyMessages: BASE_HISTORY });
const runId = await openChatAndSubmitProbe(currentPage, gateway, { deferSend: true });
// The Gateway persists and broadcasts the user turn before the ack resolves.
const userEcho = {
content: [{ text: PROBE_TEXT, type: "text" }],
role: "user",
timestamp: Date.now(),
__openclaw: { id: USER_ECHO_ENTRY_ID, idempotencyKey: runId, seq: 2 },
};
await gateway.setHistoryMessages([...BASE_HISTORY, userEcho]);
const historyRequestsBefore = (await gateway.getRequests("chat.history")).length;
await gateway.emitGatewayEvent("session.message", {
activeRunIds: [runId],
clientRunId: runId,
hasActiveRun: true,
message: userEcho,
messageId: "pending-handoff-echo-1",
messageSeq: 2,
session: {
activeRunIds: [runId],
hasActiveRun: true,
key: "main",
kind: "direct",
status: "running",
updatedAt: Date.now(),
},
sessionKey: "main",
});
await expect
.poll(async () => (await gateway.getRequests("chat.history")).length, { timeout: 10_000 })
.toBeGreaterThan(historyRequestsBefore);
await currentPage.waitForTimeout(300);
await gateway.resolveDeferred("chat.send");
const frames = await finishRunAndSettle(currentPage, gateway, runId, userEcho);
const { gapFrames, keyTimeline } = analyzeFrameSamples(frames);
expect(gapFrames).toBe(0);
expect(new Set(keyTimeline).size).toBe(1);
// The single stable key above proves the node never remounted, so the
// entry animation cannot have replayed; at most the one submitted turn
// still carries the (inert, completed) animation class.
expect(await currentPage.locator(".chat-bubble--user-turn-enter").count()).toBeLessThanOrEqual(
1,
);
});
});
+1
View File
@@ -8257,6 +8257,7 @@ describe("handleSendChat", () => {
},
],
timestamp: expect.any(Number),
__openclaw: { idempotencyKey: expect.stringMatching(/:user$/) },
},
]);
});
+8
View File
@@ -109,6 +109,7 @@ import {
formatTerminalChatSendAckError,
chatMessagesContainQueuedSend,
OFFLINE_QUEUE_STORAGE_ERROR,
preserveQueuedUserTurn,
sendQueuedChatMessageWithQueueMode as sendQueuedChatMessageWithQueueModeLifecycle,
steerQueuedChatMessage as steerQueuedChatMessageLifecycle,
type SteerSendDependencies,
@@ -814,6 +815,9 @@ async function sendQueuedChatMessage(
hasAttachments ? attachments : undefined,
),
timestamp: startedAt,
// Send identity keeps this optimistic turn on the same rendered
// bubble key as the pending row and the authoritative history copy.
__openclaw: { idempotencyKey: `${runId}:user` },
},
];
}
@@ -1390,6 +1394,10 @@ async function readCurrentStoredChatHistory(
}
syncChatQueueFromStoredOutbox(host, currentOutbox);
if (chatMessagesContainQueuedSend(history.messages, item)) {
// Server history owns the turn, but the visible transcript may not have
// reloaded yet; materialize the turn locally before dropping the queue row
// or the bubble vanishes until loadChatHistory below resolves.
preserveQueuedUserTurn(host, item);
const removed = removeQueuedMessageWithoutReleasing(host, item.id, outbox.sessionKey);
if (!removed) {
return "blocked";
+29 -2
View File
@@ -844,10 +844,28 @@ function annotateToolTurnOutcome(
return items;
}
function isPendingSendMessage(message: unknown): boolean {
export function isPendingSendMessage(message: unknown): boolean {
return asRecord(asRecord(message)?.["__openclaw"])?.kind === "pending-send";
}
/** Every projection of one composer submit (pending queue row, locally
* materialized turn, authoritative history) shares this identity so the
* rendered bubble keeps one Lit key and never remounts mid-handoff. */
function userTurnSendIdentity(message: unknown): string | null {
const record = asRecord(message);
if (typeof record?.role !== "string" || record.role.toLowerCase() !== "user") {
return null;
}
const idempotencyKey = asRecord(record["__openclaw"])?.idempotencyKey;
if (typeof idempotencyKey !== "string" || !idempotencyKey.trim()) {
return null;
}
const base = idempotencyKey.endsWith(":user")
? idempotencyKey.slice(0, -":user".length)
: idempotencyKey;
return `send:${base}`;
}
function sourceMessageId(message: unknown): string | null {
const record = asRecord(message);
if (!record) {
@@ -874,6 +892,13 @@ function transcriptMessageSourceKey(message: unknown): string | null {
if (!record) {
return null;
}
// Send identity outranks transcript ids: the same submit is re-projected with
// different id/seq metadata across the pending -> history handoff, and a key
// change there remounts the bubble (visible flicker).
const sendIdentity = userTurnSendIdentity(message);
if (sendIdentity) {
return sendIdentity;
}
const id = sourceMessageId(message);
if (id) {
return `id:${id}`;
@@ -1328,7 +1353,9 @@ function buildChatItems(props: BuildChatItemsProps): Array<ChatItem | MessageGro
}
items.push({
kind: "message",
key: `pending-send:${queued.id}`,
// Mirror buildMessageKeys for a send-identity source key so the pending
// row and its history successor resolve to the same Lit key.
key: queued.sendRunId ? `msg:send:${queued.sendRunId}:0` : `pending-send:${queued.id}`,
message,
});
};
+48 -1
View File
@@ -61,7 +61,7 @@ import { detectTextDirection } from "../../../lib/text-direction.ts";
import { getSafeLocalStorage } from "../../../local-storage.ts";
import { renderChatAvatar } from "../chat-avatar.ts";
import type { ChatRunStartupPhase } from "../chat-run-startup.ts";
import { persistedMessageEntryId } from "../chat-thread.ts";
import { isPendingSendMessage, persistedMessageEntryId } from "../chat-thread.ts";
import type { PlanStatus } from "../tool-stream.ts";
import {
visibleWorkspaceConflictPaths,
@@ -873,6 +873,9 @@ function buildGroupedMessageRenderOptions(
boardProvider: opts.boardProvider,
agentId: opts.agentId,
entryId: persistedMessageEntryId(item.message) ?? undefined,
entryAnimated:
normalizeRoleForGrouping(group.role) === "user" &&
shouldAnimateUserTurnEntry(item.key, item.message),
onOpenWorkspaceFile: opts.onOpenWorkspaceFile,
duplicateCount: item.duplicateCount ?? 1,
showReasoning: opts.showReasoning,
@@ -899,6 +902,47 @@ function buildGroupedMessageRenderOptions(
};
}
/** One-shot entry animation state for submitted user turns, keyed by message
* key (send identity). An entry records first sight for the send's lifetime —
* value is the animation start, or 0 for seen-without-animating — so
* re-renders during the animation keep the class while later renders or
* virtualizer remounts of the same (possibly still pending) row never replay
* it. Insertion-ordered cap bounds the map instead of time-based pruning,
* which would forget long-lived pending rows; keys are per-send UUIDs, so the
* map is never reset across panes or sessions. */
const userTurnEntrySeenByMessageKey = new Map<string, number>();
const USER_TURN_ENTRY_ANIMATION_WINDOW_MS = 400;
/** Only just-submitted bubbles animate; restored outbox rows render still.
* Accepted tradeoff: a full page reload within this window re-animates the
* just-submitted bubble once, which matches the fresh paint around it. */
const USER_TURN_ENTRY_FRESH_SUBMIT_MS = 2_000;
const USER_TURN_ENTRY_SEEN_CAP = 256;
function shouldAnimateUserTurnEntry(messageKey: string, message: unknown): boolean {
const now = Date.now();
const seen = userTurnEntrySeenByMessageKey.get(messageKey);
if (seen !== undefined) {
return seen > 0 && now - seen < USER_TURN_ENTRY_ANIMATION_WINDOW_MS;
}
// Only a locally pending submit starts the animation; loaded history and
// remote echoes render without one.
if (!isPendingSendMessage(message)) {
return false;
}
const submittedAt = (message as { timestamp?: unknown }).timestamp;
const freshSubmit =
typeof submittedAt === "number" && now - submittedAt < USER_TURN_ENTRY_FRESH_SUBMIT_MS;
while (userTurnEntrySeenByMessageKey.size >= USER_TURN_ENTRY_SEEN_CAP) {
const oldest = userTurnEntrySeenByMessageKey.keys().next().value;
if (oldest === undefined) {
break;
}
userTurnEntrySeenByMessageKey.delete(oldest);
}
userTurnEntrySeenByMessageKey.set(messageKey, freshSubmit ? now : 0);
return freshSubmit;
}
export function renderMessageGroup(group: MessageGroup, opts: RenderMessageGroupOptions) {
const normalizedRole = normalizeRoleForGrouping(group.role);
const isWorkspaceConflict = group.messages.every((item) =>
@@ -2678,6 +2722,8 @@ function renderGroupedMessage(
allowExternalEmbedUrls?: boolean;
onOpenWorkspaceFile?: (target: { path: string; line?: number | null }) => void;
entryId?: string;
/** Freshly submitted user turn: play the one-shot composer entry animation. */
entryAnimated?: boolean;
},
onOpenSidebar?: (content: SidebarContent) => void,
) {
@@ -2735,6 +2781,7 @@ function renderGroupedMessage(
"chat-bubble",
isToolShell ? "chat-bubble--tool-shell" : "",
opts.isStreaming ? "streaming" : "",
opts.entryAnimated ? "chat-bubble--user-turn-enter" : "",
]
.filter(Boolean)
.join(" ");
+18
View File
@@ -22,6 +22,24 @@
justify-content: flex-start;
}
/* Freshly submitted composer text flows up into the transcript. Applied only
to the locally pending bubble (see shouldAnimateUserTurnEntry); the bubble
keeps a stable key through the history handoff so this never replays. */
@keyframes chat-user-turn-enter {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.chat-bubble--user-turn-enter {
animation: chat-user-turn-enter var(--duration-normal) var(--ease-out) backwards;
}
.chat-group.chat-group--with-footer {
--chat-group-avatar-column: 1;
--chat-group-content-column: 2;