Skip to content

Commit 9ac52a8

Browse files
committed
fix(ui): prevent false busy state in Control UI webchat
- Added showLocalAbortableUi to gate busy UI on actual stream activity - Only show in-progress badge and Stop button when there is active stream or sending - Added e2e test for stale session state not forcing busy UI - Added unit tests for idle composer with stale session state and active stream Fixes #87387
1 parent b3c9469 commit 9ac52a8

3 files changed

Lines changed: 134 additions & 3 deletions

File tree

ui/src/ui/e2e/chat-flow.e2e.test.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,17 @@ function requireString(value: unknown, label: string): string {
3232
return value;
3333
}
3434

35+
async function waitForUiFrame(page: Page): Promise<void> {
36+
await page.evaluate(
37+
() =>
38+
new Promise<void>((resolve) => {
39+
requestAnimationFrame(() => {
40+
requestAnimationFrame(() => resolve());
41+
});
42+
}),
43+
);
44+
}
45+
3546
async function waitForRequests(
3647
gateway: Awaited<ReturnType<typeof installMockGateway>>,
3748
method: string,
@@ -880,4 +891,65 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => {
880891
await context.close();
881892
}
882893
});
894+
895+
it("returns to idle when stale active session state arrives after completion", async () => {
896+
const context = await browser.newContext({
897+
locale: "en-US",
898+
serviceWorkers: "block",
899+
viewport: { height: 900, width: 1280 },
900+
});
901+
const page = await context.newPage();
902+
const gateway = await installMockGateway(page, {
903+
historyMessages: [
904+
{
905+
content: [{ text: "Ready for stale-state proof.", type: "text" }],
906+
role: "assistant",
907+
timestamp: Date.now(),
908+
},
909+
],
910+
});
911+
912+
try {
913+
await page.goto(`${server.baseUrl}chat`);
914+
await page.getByText("Ready for stale-state proof.").waitFor({ timeout: 10_000 });
915+
await page.getByRole("button", { name: "Send message" }).waitFor({ timeout: 10_000 });
916+
917+
const prompt = "complete and stay idle";
918+
await page.locator(".agent-chat__composer-combobox textarea").fill(prompt);
919+
await page.getByRole("button", { name: "Send message" }).click();
920+
921+
const sendRequest = await gateway.waitForRequest("chat.send");
922+
const params = requireRecord(sendRequest.params);
923+
const runId = requireString(params.idempotencyKey, "chat send idempotency key");
924+
await gateway.emitChatFinal({ runId, text: "Completed without a stuck composer." });
925+
926+
await page.getByText("Completed without a stuck composer.").waitFor({ timeout: 10_000 });
927+
await page.locator(".agent-chat__run-status--done").waitFor({ timeout: 10_000 });
928+
await page
929+
.locator(".agent-chat__run-status--done")
930+
.waitFor({ state: "hidden", timeout: 7_000 });
931+
932+
await gateway.emitGatewayEvent("sessions.changed", {
933+
hasActiveRun: true,
934+
key: "main",
935+
kind: "direct",
936+
sessionKey: "main",
937+
status: "running",
938+
updatedAt: Date.now(),
939+
});
940+
await waitForUiFrame(page);
941+
942+
await page
943+
.locator(".agent-chat__run-status--in-progress")
944+
.waitFor({ state: "hidden", timeout: 2_000 });
945+
await page.getByRole("button", { name: "Stop generating" }).waitFor({
946+
state: "hidden",
947+
timeout: 2_000,
948+
});
949+
await page.getByRole("button", { name: "Send message" }).waitFor({ timeout: 2_000 });
950+
expect(await page.getByRole("button", { name: "Queue message" }).count()).toBe(0);
951+
} finally {
952+
await context.close();
953+
}
954+
});
883955
});

ui/src/ui/views/chat.test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1228,6 +1228,62 @@ describe("chat loading skeleton", () => {
12281228
nowSpy.mockRestore();
12291229
}
12301230
});
1231+
1232+
it("keeps stale abortable session rows from forcing idle composer busy UI", () => {
1233+
const container = renderChatView({
1234+
canAbort: true,
1235+
messages: [
1236+
{
1237+
role: "assistant",
1238+
content: "Finished answer",
1239+
timestamp: 1,
1240+
},
1241+
],
1242+
queue: [{ id: "queued-1", text: "follow up", createdAt: 2 }],
1243+
sessions: {
1244+
ts: 0,
1245+
path: "",
1246+
count: 1,
1247+
defaults: { modelProvider: null, model: null, contextTokens: 200_000 },
1248+
sessions: [
1249+
{
1250+
key: "main",
1251+
kind: "direct",
1252+
updatedAt: null,
1253+
hasActiveRun: true,
1254+
status: "running",
1255+
totalTokens: 190_000,
1256+
contextTokens: 200_000,
1257+
},
1258+
],
1259+
},
1260+
onCompact: () => undefined,
1261+
onQueueSteer: () => undefined,
1262+
});
1263+
1264+
expect(container.querySelector(".agent-chat__run-status--in-progress")).toBeNull();
1265+
expect(container.querySelector(".chat-reading-indicator")).toBeNull();
1266+
expect(container.querySelector(".chat-send-btn--stop")).toBeNull();
1267+
expect(container.querySelector(".chat-queue__steer")).toBeNull();
1268+
const sendButton = container.querySelector<HTMLButtonElement>(".chat-send-btn");
1269+
expect(sendButton?.getAttribute("aria-label")).toBe("Send message");
1270+
expect(sendButton?.textContent?.trim()).toBe("Send");
1271+
});
1272+
1273+
it("keeps abort controls visible while a local stream is active", () => {
1274+
const container = renderChatView({
1275+
canAbort: true,
1276+
stream: "",
1277+
queue: [{ id: "queued-1", text: "follow up", createdAt: 2 }],
1278+
onQueueSteer: () => undefined,
1279+
});
1280+
1281+
expect(container.querySelector(".agent-chat__run-status--in-progress")?.textContent).toContain(
1282+
"In progress",
1283+
);
1284+
expect(container.querySelector(".chat-send-btn--stop")?.textContent?.trim()).toBe("Stop");
1285+
expect(container.querySelector(".chat-queue__steer")?.textContent?.trim()).toBe("Steer");
1286+
});
12311287
});
12321288

12331289
describe("chat voice controls", () => {

ui/src/ui/views/chat.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1570,7 +1570,10 @@ export function renderChat(props: ChatProps) {
15701570
const isBusy = props.sending || props.stream !== null;
15711571
const canAbort = Boolean(props.canAbort && props.onAbort);
15721572
const showAbortableUi = canAbort && !hasTerminalRunStatus(props.runStatus);
1573-
const composerRunStatus = showAbortableUi ? { phase: "in-progress" as const } : props.runStatus;
1573+
const showLocalAbortableUi = showAbortableUi && isBusy;
1574+
const composerRunStatus = showLocalAbortableUi
1575+
? { phase: "in-progress" as const }
1576+
: props.runStatus;
15741577
const compactBusy =
15751578
props.compactionStatus?.phase === "active" || props.compactionStatus?.phase === "retrying";
15761579
const activeSession = props.sessions?.sessions?.find((row) => row.key === props.sessionKey);
@@ -2081,7 +2084,7 @@ export function renderChat(props: ChatProps) {
20812084
20822085
${renderChatQueue({
20832086
queue: props.queue,
2084-
canAbort: showAbortableUi,
2087+
canAbort: showLocalAbortableUi,
20852088
onQueueRetry: props.onQueueRetry,
20862089
onQueueSteer: props.onQueueSteer,
20872090
onQueueRemove: props.onQueueRemove,
@@ -2232,7 +2235,7 @@ export function renderChat(props: ChatProps) {
22322235
? html`<div class="agent-chat__composer-controls">${composerControls}</div>`
22332236
: nothing}
22342237
${renderChatRunControls({
2235-
canAbort: showAbortableUi,
2238+
canAbort: showLocalAbortableUi,
22362239
connected: props.connected,
22372240
draft: visibleDraft,
22382241
hasMessages: props.messages.length > 0,

0 commit comments

Comments
 (0)