From b7b38412a0bf40da1e91d30d937c863f0d5964c1 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 7 Nov 2026 21:32:14 +0700 Subject: [PATCH] Keep turn footer timing within the available chat width (#9007) * fix(app): keep turn footer timing within the chat width Let visible timing text determine its height within the available chat width instead of sizing an absolute overlay by character count. Keep actions anchored when duration and timestamp labels swap. * fix(app): keep footer timestamp hover target stable * fix(app): reserve both wrapped timing label heights --- .../app/e2e/browser/agent-turn-end.spec.ts | 78 +++++++++++++++++ packages/app/src/agent-stream/turn-footer.tsx | 4 +- packages/app/src/components/message.tsx | 83 +++++++++++++------ 3 files changed, 138 insertions(+), 27 deletions(-) diff --git a/packages/app/e2e/browser/agent-turn-end.spec.ts b/packages/app/e2e/browser/agent-turn-end.spec.ts index 523297729c..82e6457555 100644 --- a/packages/app/e2e/browser/agent-turn-end.spec.ts +++ b/packages/app/e2e/browser/agent-turn-end.spec.ts @@ -198,3 +198,81 @@ test.describe("Turn end", () => { expectSteadyTurnEnd(await recordTurnEnd(page, agent, input), input); }); }); + +async function expectTimingInsideFooter(page: Page): Promise { + const label = page.getByRole("button", { name: /^Worked for/ }).last(); + const paintedLines = await label.getByTestId("turn-footer-timing-label").evaluate((element) => { + const labelBounds = element.parentElement!.getBoundingClientRect(); + const rowBounds = element.closest('[data-testid="turn-footer"]')!.getBoundingClientRect(); + const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT); + const lines: boolean[] = []; + while (walker.nextNode()) { + const range = document.createRange(); + range.selectNodeContents(walker.currentNode); + for (const rect of range.getClientRects()) { + lines.push( + rect.left >= Math.max(rowBounds.left, labelBounds.left) - 0.5 && + rect.right <= Math.min(rowBounds.right, labelBounds.right) + 0.5 && + rect.top >= Math.max(rowBounds.top, labelBounds.top) - 0.5 && + rect.bottom <= Math.min(rowBounds.bottom, labelBounds.bottom) + 0.5, + ); + } + } + return lines; + }); + expect(paintedLines.length).toBeGreaterThan(0); + expect(paintedLines.every(Boolean)).toBe(true); +} + +async function expectFooterWithinWidth(page: Page, width: number): Promise { + const footer = page.getByTestId("turn-footer").last(); + await footer.evaluate((element, availableWidth) => { + element.style.width = `${availableWidth}px`; + }, width); + const label = page.getByRole("button", { name: /^Worked for/ }).last(); + await expect(label).toBeVisible(); + await expectTimingInsideFooter(page); + const controls = footer.getByRole("button", { name: /Copy turn|Fork chat/ }); + const before = await controls.evaluateAll((elements) => + elements.map((element) => { + const rect = element.getBoundingClientRect(); + return { x: rect.x, y: rect.y }; + }), + ); + const footerBefore = await footer.boundingBox(); + const labelBefore = await label.boundingBox(); + await label.hover({ position: { x: labelBefore!.width - 2, y: labelBefore!.height - 2 } }); + await expect(label.getByTestId("turn-footer-timing-label")).toContainText(/\d:\d\d/); + expect(await label.boundingBox()).toMatchObject({ + x: labelBefore!.x, + width: labelBefore!.width, + height: labelBefore!.height, + }); + expect( + await controls.evaluateAll((elements) => + elements.map((element) => { + const rect = element.getBoundingClientRect(); + return { x: rect.x, y: rect.y }; + }), + ), + ).toEqual(before); + expect(await footer.boundingBox()).toMatchObject({ + x: footerBefore!.x, + y: footerBefore!.y, + width: footerBefore!.width, + }); + await expectTimingInsideFooter(page); + await page.mouse.move(0, 0); + await expect(label.getByTestId("turn-footer-timing-label")).toContainText("Worked for"); +} + +test("footer timing fits the chat column and swapping labels keeps controls in place", async ({ + page, + agent, +}) => { + await openAgentRoute(page, agent); + await expectFooterWithinWidth(page, 140); + await expectFooterWithinWidth(page, 150); + await expectFooterWithinWidth(page, 300); + await expectFooterWithinWidth(page, 880); +}); diff --git a/packages/app/src/agent-stream/turn-footer.tsx b/packages/app/src/agent-stream/turn-footer.tsx index 08fceb4005..6699fb800b 100644 --- a/packages/app/src/agent-stream/turn-footer.tsx +++ b/packages/app/src/agent-stream/turn-footer.tsx @@ -236,8 +236,8 @@ const stylesheet = StyleSheet.create((theme) => ({ turnFooterSlot: { flexDirection: "row", alignItems: "center", - alignSelf: "flex-start", - height: TURN_FOOTER_CONTENT_HEIGHT + TURN_FOOTER_BOTTOM_SPACING, + alignSelf: "stretch", + minHeight: TURN_FOOTER_CONTENT_HEIGHT + TURN_FOOTER_BOTTOM_SPACING, paddingBottom: TURN_FOOTER_BOTTOM_SPACING, }, turnFooterContent: { diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index e73dd4171f..2bc96f6b97 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -557,6 +557,15 @@ interface AssistantTurnFooterProps { const assistantTurnFooterStylesheet = StyleSheet.create((theme) => ({ container: { + flex: 1, + minWidth: 0, + minHeight: 24, + flexDirection: "row", + alignItems: "flex-start", + gap: theme.spacing[2], + }, + actions: { + height: 24, flexDirection: "row", alignItems: "center", gap: theme.spacing[2], @@ -568,21 +577,25 @@ const assistantTurnFooterStylesheet = StyleSheet.create((theme) => ({ marginTop: 0, marginLeft: -theme.spacing[1], }, - labelWrapper: { - position: "relative", + labelButton: { + flex: 1, + minHeight: 24, + flexDirection: "row", + alignItems: "center", + minWidth: 0, }, - labelSizer: { + label: { + width: "100%", + flexShrink: 0, color: theme.colors.foregroundMuted, fontSize: STREAM_METADATA_FONT_SIZE, + }, + overlappingLabel: { + marginLeft: "-100%", + }, + hiddenLabel: { opacity: 0, }, - labelOverlay: { - position: "absolute", - top: 0, - left: 0, - color: theme.colors.foregroundMuted, - fontSize: STREAM_METADATA_FONT_SIZE, - }, })); const TIMESTAMP_REVEAL_MS = 3000; @@ -649,31 +662,51 @@ export const AssistantTurnFooter = memo(function AssistantTurnFooter({ return ( - {getContent ? ( - + {getContent || canFork ? ( + + {getContent ? ( + + ) : null} + {canFork ? : null} + ) : null} - {canFork ? : null} {primaryLabel ? ( - - {/* Sizer reserves space for whichever label is longer so the - container width is stable across hover transitions. */} - - {primaryLabel.length >= timestampLabel.length ? primaryLabel : timestampLabel} + {/* Both labels participate in layout at the available width. Overlapping their + columns reserves the taller wrapped height without measuring text or characters. */} + + {primaryLabel} + + {canSwap ? ( + + {timestampLabel} - - {showTimestamp ? timestampLabel : primaryLabel} - - + ) : null} ) : null}