From 827fde28857e759d3628b20de668eaca0d6cebeb Mon Sep 17 00:00:00 2001 From: "Vyctor H. Brzezowski" Date: Mon, 17 Aug 2026 00:10:48 -0300 Subject: [PATCH] fix(ui): hide mobile transcript metadata until tap (#124936) * fix(ui): reveal mobile message metadata on tap * fix(ui): centralize mobile transcript disclosure Co-authored-by: Vyctor H. Brzezowski <51521767+vyctorbrzezowski@users.noreply.github.com> --------- Co-authored-by: RoboClaw <309084314+roboclaw-bot@users.noreply.github.com> --- ui/src/e2e/chat-flow.streaming.e2e.test.ts | 71 ++++++++++++++++++ .../components/chat-thread-interactions.ts | 32 +++++++- ui/src/pages/chat/components/chat-thread.ts | 4 +- .../components/chat-transcript-render.test.ts | 74 +++++++++++++++++++ ui/src/styles/chat/grouped.css | 50 ++++++------- 5 files changed, 203 insertions(+), 28 deletions(-) diff --git a/ui/src/e2e/chat-flow.streaming.e2e.test.ts b/ui/src/e2e/chat-flow.streaming.e2e.test.ts index dc73e6fd9aeb..5375295a9791 100644 --- a/ui/src/e2e/chat-flow.streaming.e2e.test.ts +++ b/ui/src/e2e/chat-flow.streaming.e2e.test.ts @@ -16,6 +16,77 @@ import { const suite = createChatFlowE2eSuite(); suite.define(() => { + it("reveals an active stream footer after a mobile tap", async () => { + const artifactDir = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim(); + const context = await suite.newBrowserContext({ + hasTouch: true, + isMobile: true, + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 844, width: 390 }, + }); + const page = await context.newPage(); + const gateway = await installMockGateway(page); + + try { + await page.goto(`${suite.server.baseUrl}chat`); + await page.locator(".agent-chat__composer-combobox textarea").fill("show stream metadata"); + await page.getByRole("button", { name: "Send message" }).click(); + const sendRequest = await gateway.waitForRequest("chat.send"); + const runId = requireString( + requireRecord(sendRequest.params).idempotencyKey, + "chat send idempotency key", + ); + const streamingText = "This response is still streaming."; + await gateway.emitGatewayEvent("chat", { + deltaText: streamingText, + message: { + content: [{ text: streamingText, type: "text" }], + role: "assistant", + timestamp: Date.now(), + }, + runId, + sessionKey: "main", + state: "delta", + }); + + const activeStream = page.locator(".chat-bubble.streaming"); + await activeStream.waitFor({ state: "visible", timeout: 10_000 }); + const footer = activeStream + .locator( + "xpath=ancestor::*[contains(concat(' ', normalize-space(@class), ' '), ' chat-group ')][1]", + ) + .locator(".chat-group-footer"); + await footer.waitFor({ state: "attached", timeout: 10_000 }); + const presentation = () => + footer.evaluate((element) => { + const style = getComputedStyle(element); + return { opacity: style.opacity, pointerEvents: style.pointerEvents }; + }); + await expect.poll(presentation).toEqual({ opacity: "0", pointerEvents: "none" }); + + if (artifactDir) { + await mkdir(artifactDir, { recursive: true }); + await page.screenshot({ + fullPage: true, + path: path.join(artifactDir, "active-stream-metadata-resting.png"), + }); + } + + await activeStream.tap(); + await expect.poll(presentation).toEqual({ opacity: "1", pointerEvents: "auto" }); + + if (artifactDir) { + await page.screenshot({ + fullPage: true, + path: path.join(artifactDir, "active-stream-metadata-revealed.png"), + }); + } + } finally { + await suite.closeBrowserContext(context); + } + }); + it("keeps streamed audio and video metadata pinned without overriding manual scroll", async () => { const artifactDir = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim(); const context = await suite.newBrowserContext({ diff --git a/ui/src/pages/chat/components/chat-thread-interactions.ts b/ui/src/pages/chat/components/chat-thread-interactions.ts index 1c687e739227..873fe2317d71 100644 --- a/ui/src/pages/chat/components/chat-thread-interactions.ts +++ b/ui/src/pages/chat/components/chat-thread-interactions.ts @@ -354,7 +354,37 @@ function createMessageActionContextButton(params: { return { element: tooltip, button }; } -export function handleTranscriptSelection(event: PointerEvent, props: TranscriptInteractionProps) { +function toggleTouchMessageMeta(event: PointerEvent): void { + const transcript = event.currentTarget; + const target = event.target; + if ( + event.pointerType !== "touch" || + !(transcript instanceof HTMLElement) || + !(target instanceof Element) + ) { + return; + } + const group = target.closest(".chat-group--with-footer"); + if ( + !(group instanceof HTMLElement) || + !transcript.contains(group) || + target.closest("a, button, details, input, label, select, textarea, [contenteditable]") + ) { + return; + } + const selection = window.getSelection(); + if (selection && !selection.isCollapsed) { + return; + } + const reveal = !group.classList.contains("chat-group--meta-revealed"); + for (const revealed of transcript.querySelectorAll(".chat-group--meta-revealed")) { + revealed.classList.remove("chat-group--meta-revealed"); + } + group.classList.toggle("chat-group--meta-revealed", reveal); +} + +export function handleTranscriptPointerUp(event: PointerEvent, props: TranscriptInteractionProps) { + toggleTouchMessageMeta(event); if ( typeof props.onCompanionQuestion !== "function" || typeof props.onCompanionPrefill !== "function" diff --git a/ui/src/pages/chat/components/chat-thread.ts b/ui/src/pages/chat/components/chat-thread.ts index b4ee4fbf2b70..f9eaf480bfd9 100644 --- a/ui/src/pages/chat/components/chat-thread.ts +++ b/ui/src/pages/chat/components/chat-thread.ts @@ -8,7 +8,7 @@ import { import { t } from "../../../i18n/index.ts"; import { handleTranscriptContextMenu, - handleTranscriptSelection, + handleTranscriptPointerUp, type ChatThreadProps, } from "./chat-thread-interactions.ts"; import { @@ -136,7 +136,7 @@ function renderTranscriptShell( } }} @contextmenu=${(event: MouseEvent) => handleTranscriptContextMenu(event, props)} - @pointerup=${(event: PointerEvent) => handleTranscriptSelection(event, props)} + @pointerup=${(event: PointerEvent) => handleTranscriptPointerUp(event, props)} > (selector); + if (!element) { + throw new Error(`expected ${selector}`); + } + return element; +} + +function requireClosest(element: Element, selector: string): HTMLElement { + const closest = element.closest(selector); + if (!closest) { + throw new Error(`expected closest ${selector}`); + } + return closest; +} + +function touchPointerUp(element: Element): void { + const event = new Event("pointerup", { bubbles: true }); + Object.defineProperty(event, "pointerType", { value: "touch" }); + element.dispatchEvent(event); +} + describe("chat transcript rendering", () => { beforeEach(installTranscriptDomMocks); afterEach(resetTranscriptTestDom); + it("reveals touched metadata across stored and live groups within one transcript", async () => { + const firstTranscript = createTestTranscript(); + const secondTranscript = createTestTranscript(); + const firstContainer = document.body.appendChild(document.createElement("div")); + const secondContainer = document.body.appendChild(document.createElement("div")); + const firstProps = { + ...threadProps("pane-touch-first", "agent:main:first", [ + { role: "user", content: "Stored message", timestamp: 1_000 }, + ]), + stream: "Live reply", + streamStartedAt: 2_000, + }; + const secondProps = threadProps("pane-touch-second", "agent:main:second", [ + { role: "assistant", content: "Other transcript", timestamp: 3_000 }, + ]); + render(renderChatThread(firstProps, firstTranscript), firstContainer); + render(renderChatThread(secondProps, secondTranscript), secondContainer); + firstTranscript.hostConnected(); + secondTranscript.hostConnected(); + firstTranscript.hostUpdated(); + secondTranscript.hostUpdated(); + await flushDeferredRowPrune(); + + const storedGroup = requireElement(firstContainer, ".chat-group.user"); + const storedBubble = requireElement(storedGroup, ".chat-bubble"); + const streamBubble = requireElement(firstContainer, ".chat-bubble.streaming"); + const streamGroup = requireClosest(streamBubble, ".chat-group--with-footer"); + const secondGroup = requireElement(secondContainer, ".chat-group.assistant"); + + storedBubble.dispatchEvent(new Event("pointerup", { bubbles: true })); + expect(storedGroup.classList.contains("chat-group--meta-revealed")).toBe(false); + + touchPointerUp(storedBubble); + expect(storedGroup.classList.contains("chat-group--meta-revealed")).toBe(true); + + touchPointerUp(streamBubble); + expect(storedGroup.classList.contains("chat-group--meta-revealed")).toBe(false); + expect(streamGroup.classList.contains("chat-group--meta-revealed")).toBe(true); + + touchPointerUp(requireElement(secondGroup, ".chat-bubble")); + expect(secondGroup.classList.contains("chat-group--meta-revealed")).toBe(true); + expect(streamGroup.classList.contains("chat-group--meta-revealed")).toBe(true); + + touchPointerUp(requireElement(secondGroup, ".chat-copy-btn")); + expect(secondGroup.classList.contains("chat-group--meta-revealed")).toBe(true); + + touchPointerUp(requireElement(secondGroup, ".chat-bubble")); + expect(secondGroup.classList.contains("chat-group--meta-revealed")).toBe(false); + firstTranscript.hostDisconnected(); + secondTranscript.hostDisconnected(); + }); + it("resolves persisted replies to their source and highlights it on click", async () => { const transcript = createTestTranscript(); const container = document.body.appendChild(document.createElement("div")); diff --git a/ui/src/styles/chat/grouped.css b/ui/src/styles/chat/grouped.css index 1d890be84ca3..fcdbfc922736 100644 --- a/ui/src/styles/chat/grouped.css +++ b/ui/src/styles/chat/grouped.css @@ -131,8 +131,8 @@ width: 100%; min-width: 0; min-height: 24px; - opacity: 0; - pointer-events: none; + opacity: var(--chat-footer-disclosure-opacity, 0); + pointer-events: var(--chat-footer-disclosure-pointer-events, none); transition: opacity 120ms ease-out; } @@ -147,7 +147,7 @@ other footer details stay focusable but leave the row layout until reveal. */ .chat-group-footer--persistent-identity { position: relative; - opacity: 1; + opacity: var(--chat-persistent-footer-opacity, 1); pointer-events: none; } @@ -155,9 +155,9 @@ .chat-group-footer--persistent-identity .chat-group-timestamp, .chat-group-footer--persistent-identity .msg-meta, .chat-group-footer--persistent-identity .chat-group-footer-actions { - position: absolute; - opacity: 0; - pointer-events: none; + position: var(--chat-footer-detail-position, absolute); + opacity: var(--chat-footer-disclosure-opacity, 0); + pointer-events: var(--chat-footer-disclosure-pointer-events, none); } .chat-group:hover .chat-group-footer--persistent-identity, @@ -302,8 +302,8 @@ min-height: 24px; border-radius: var(--radius-sm, 4px); color: var(--muted); - opacity: 0; - pointer-events: none; + opacity: var(--chat-footer-disclosure-opacity, 0); + pointer-events: var(--chat-footer-disclosure-pointer-events, none); transition: opacity 120ms ease-out, color 120ms ease-out, @@ -695,32 +695,32 @@ img.chat-avatar.chat-avatar--logo { @media (hover: none), (max-width: 768px), (max-width: 932px) and (max-height: 500px) and (orientation: landscape) { - /* No hover to reveal the footer: keep it always visible and add a little - more separation from the next group. */ + /* Shared message renderers stay touch-readable by default. Transcript rows + opt into one-at-a-time disclosure through their owning thread. */ .chat-group { + --chat-footer-disclosure-opacity: 1; + --chat-footer-disclosure-pointer-events: auto; + --chat-footer-detail-position: static; padding-bottom: 0; margin-bottom: 14px; } - .chat-group-footer--persistent-identity .chat-confirm-wrap, - .chat-group-footer--persistent-identity .chat-group-timestamp, - .chat-group-footer--persistent-identity .msg-meta, - .chat-group-footer--persistent-identity .chat-group-footer-actions { - position: static; - opacity: 1; - pointer-events: auto; - } - .chat-group-footer { margin-top: 4px; - opacity: 1; - pointer-events: auto; } - .chat-group-footer-actions button, - .chat-message-actions-row button { - opacity: 1; - pointer-events: auto; + :where(.chat-thread) .chat-group { + --chat-footer-disclosure-opacity: 0; + --chat-footer-disclosure-pointer-events: none; + --chat-footer-detail-position: absolute; + --chat-persistent-footer-opacity: 0; + } + + :where(.chat-thread) .chat-group:is(.chat-group--meta-revealed, :focus-within) { + --chat-footer-disclosure-opacity: 1; + --chat-footer-disclosure-pointer-events: auto; + --chat-footer-detail-position: static; + --chat-persistent-footer-opacity: 1; } .chat-group-footer-actions .chat-copy-btn,