mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-19 17:11:42 -06:00
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:
committed by
GitHub
parent
fc8ee406a2
commit
827fde2885
@@ -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"
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user