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>
This commit is contained in:
Vyctor H. Brzezowski
2026-08-17 00:10:48 -03:00
committed by GitHub
parent fc8ee406a2
commit 827fde2885
5 changed files with 203 additions and 28 deletions
@@ -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({
@@ -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"
+2 -2
View File
@@ -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)}
>
<span
class="chat-transcript-announcement sr-only"
@@ -12,10 +12,84 @@ import {
threadProps,
} from "./chat-transcript.test-support.ts";
function requireElement(container: ParentNode, selector: string): HTMLElement {
const element = container.querySelector<HTMLElement>(selector);
if (!element) {
throw new Error(`expected ${selector}`);
}
return element;
}
function requireClosest(element: Element, selector: string): HTMLElement {
const closest = element.closest<HTMLElement>(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"));
+25 -25
View File
@@ -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,