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}