fix(ui): scroll pending sends into view

Scroll the chat thread as soon as a submitted pending send is enqueued, so delayed `chat.send` ACKs no longer leave the user's just-sent message below the viewport.

Verification:
- focused UI Vitest suite: 86 tests passed
- oxlint, core tsgo, core-test tsgo, diff check
- Testbox changed gate: tbx_01kt0wspy1ks5wpb6kp5gr0512
- branch autoreview clean
This commit is contained in:
Vincent Koc
2026-06-01 07:14:07 +01:00
committed by GitHub
parent 4d49a76039
commit 3e91c688ae
2 changed files with 66 additions and 1 deletions

View File

@@ -417,6 +417,7 @@ function enqueuePendingSendMessage(
};
host.chatQueue = [...host.chatQueue, pending];
recordChatSendTiming(host, pending, "pending-visible", submittedAtMs);
scheduleChatScroll(host as unknown as Parameters<typeof scheduleChatScroll>[0], true);
return pending;
}

View File

@@ -1,4 +1,4 @@
import { chromium, type Browser } from "playwright";
import { chromium, type Browser, type Page } from "playwright";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
canRunPlaywrightChromium,
@@ -49,6 +49,13 @@ async function waitForRequests(
throw new Error(`Timed out waiting for ${count} ${method} requests`);
}
async function chatThreadDistanceFromBottom(page: Page): Promise<number> {
return page.locator(".chat-thread").evaluate((element) => {
const thread = element as HTMLElement;
return Math.round(thread.scrollHeight - thread.scrollTop - thread.clientHeight);
});
}
function chatSessionListResponse() {
return {
count: 2,
@@ -322,6 +329,63 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => {
}
});
it("scrolls a delayed pending send into view before the ACK resolves", async () => {
const context = await browser.newContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
const baseTs = Date.now() - 100_000;
const historyMessages = Array.from({ length: 50 }, (_, index) => ({
content: [
{
text: `History message ${index}\n${"extra transcript line\n".repeat(4)}`,
type: "text",
},
],
role: index % 2 === 0 ? "assistant" : "user",
timestamp: baseTs + index,
}));
const gateway = await installMockGateway(page, { historyMessages });
try {
await page.goto(`${server.baseUrl}chat`);
await page.getByText("History message 49").waitFor({ timeout: 10_000 });
await expect
.poll(() => chatThreadDistanceFromBottom(page), { timeout: 10_000 })
.toBeLessThanOrEqual(4);
await page.locator(".chat-thread").evaluate((element) => {
(element as HTMLElement).scrollTop = 0;
});
await expect
.poll(() => chatThreadDistanceFromBottom(page), { timeout: 10_000 })
.toBeGreaterThan(200);
await gateway.deferNext("chat.send");
const prompt = `pending send should scroll before ack\n${"visible now\n".repeat(6)}`;
await page.locator(".agent-chat__composer-combobox textarea").fill(prompt);
await page.getByRole("button", { name: "Send message" }).click();
const sendRequest = await gateway.waitForRequest("chat.send");
const params = requireRecord(sendRequest.params);
const runId = requireString(params.idempotencyKey, "chat send idempotency key");
await page.locator(".chat-thread").getByText("pending send should scroll").waitFor({
timeout: 10_000,
});
await expect
.poll(() => chatThreadDistanceFromBottom(page), { timeout: 10_000 })
.toBeLessThanOrEqual(4);
await gateway.resolveDeferred("chat.send", { runId, status: "started" });
} finally {
await context.close();
}
});
it("keeps rejected pre-ACK sends visible and restores the draft", async () => {
const context = await browser.newContext({
locale: "en-US",