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}