From 2d7a1156149b389d6a7f503601d31a5f612148ef Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 8 Nov 2026 12:48:07 +0700 Subject: [PATCH] fix(app): keep the latest turn footer in the chat column on web (#9083) At widths beyond the chat's max width, the latest turn's footer sat at the chat's left padding instead of under the message column, while running and after it ended. Earlier footers were aligned. #8358 wrapped the web viewport's live auxiliary (the latest turn footer and pending permissions) in a measuring div with no style. That div is a block box, so the footer row's alignSelf: center no longer applied. Give it the same flex column style as the stream rows. Fixes #9081 --- .../app/e2e/browser/agent-turn-end.spec.ts | 49 +++++++++++++++++++ .../app/src/agent-stream/strategy-web.tsx | 6 ++- 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/packages/app/e2e/browser/agent-turn-end.spec.ts b/packages/app/e2e/browser/agent-turn-end.spec.ts index 82e6457555..4c8a1828b1 100644 --- a/packages/app/e2e/browser/agent-turn-end.spec.ts +++ b/packages/app/e2e/browser/agent-turn-end.spec.ts @@ -276,3 +276,52 @@ test("footer timing fits the chat column and swapping labels keeps controls in p await expectFooterWithinWidth(page, 300); await expectFooterWithinWidth(page, 880); }); + +async function visibleFooterLeftEdges(page: Page): Promise { + return page.evaluate(() => { + const viewport = Array.from( + document.querySelectorAll('[data-testid="agent-chat-scroll"]'), + ).find((candidate) => candidate.checkVisibility()); + return Array.from(viewport?.querySelectorAll('[data-testid="turn-footer"]') ?? []) + .filter((footer) => footer.checkVisibility()) + .map((footer) => Math.round(footer.getBoundingClientRect().left)); + }); +} + +async function expectFootersInOneColumn(page: Page): Promise { + await expect + .poll(async () => { + const edges = await visibleFooterLeftEdges(page); + return edges.length >= 2 && new Set(edges).size === 1 ? "one column" : edges; + }) + .toBe("one column"); +} + +test("keeps the latest turn's footer in the chat column while it runs and after it ends", async ({ + page, + agent, +}) => { + // Wider than the chat's max width, so the column is centered with space on both sides. + await page.setViewportSize({ width: 1600, height: 1000 }); + const gate = await installDaemonWebSocketGate(page); + await openAgentRoute(page, agent); + await expectAgentIdle(page); + + gate.holdNextAgentStreamEvent("turn_completed"); + gate.holdNextAgentUpdate(agent.agentId, "idle"); + gate.setServerMessageSuppressed("agent_update", true); + await submitMessage(page, "emit 1 coalesced agent stream updates"); + await Promise.all([ + gate.waitForHeldAgentStreamEvent("turn_completed"), + gate.waitForHeldAgentUpdate(agent.agentId, "idle"), + ]); + await expect(page.getByTestId("turn-working-indicator")).toBeVisible(); + await expectFootersInOneColumn(page); + + gate.releaseHeldAgentStreamEvent("turn_completed"); + gate.releaseHeldAgentUpdate(agent.agentId, "idle"); + gate.setServerMessageSuppressed("agent_update", false); + await expectAgentIdle(page); + await expect(page.getByTestId("turn-working-indicator")).toHaveCount(0); + await expectFootersInOneColumn(page); +}); diff --git a/packages/app/src/agent-stream/strategy-web.tsx b/packages/app/src/agent-stream/strategy-web.tsx index 6552b6fb77..69867fb60d 100644 --- a/packages/app/src/agent-stream/strategy-web.tsx +++ b/packages/app/src/agent-stream/strategy-web.tsx @@ -82,6 +82,8 @@ const initialFillStyle: CSSProperties = { pointerEvents: "none", }; +// Rows and the live auxiliary (turn footer, pending permissions) center their content with +// `alignSelf`, which only applies inside a flex container. const streamRowStyle: CSSProperties = { display: "flex", flexDirection: "column", @@ -1362,7 +1364,9 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool ...mountedRows, ]} -
{liveAuxiliary}
+
+ {liveAuxiliary} +
{shouldRenderEmpty ? listEmptyComponent : null}