fix(ui): reveal message context on timestamp hover (#100391)

* fix(ui): reveal message context from timestamp

* fix(ui): paint message context preview

* docs(changelog): note timestamp context reveal
This commit is contained in:
Peter Steinberger
2026-07-05 10:48:22 -07:00
committed by GitHub
parent 35272d01b5
commit d84314d6b7
5 changed files with 177 additions and 46 deletions

View File

@@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai
### Changes
- **Control UI message context:** reveal per-message token, context, and model details from the timestamp on hover or activation instead of showing a separate Context button.
- **Control UI session titles:** reveal truncated recent-session names with a reduced-motion-safe hover animation.
- **Control UI sidebar usage:** remove the provider usage quota row from the expanded sidebar while keeping usage details available in the chat composer and Usage page. Thanks @shakkernerd.

View File

@@ -535,6 +535,46 @@ describeBrowserLayout("chat responsive browser layout", () => {
}
});
it("reveals message context on timestamp hover and keeps click-to-open", async () => {
if (!realChatServer) {
throw new Error("Expected the Control UI server to be ready");
}
const page = await openBrowserPage(1366, 900);
try {
await installMockGateway(page, {
assistantName: "Claw",
historyMessages: [
{
content: [{ text: "Context hover regression fixture.", type: "text" }],
model: "openai/gpt-5.5",
role: "assistant",
timestamp: Date.UTC(2026, 6, 5, 9, 51),
usage: { cacheRead: 2_400, input: 19_600, output: 126 },
},
],
});
await page.goto(`${realChatServer.baseUrl}chat`);
await page.getByText("Context hover regression fixture.").waitFor({ timeout: 10_000 });
const details = page.locator("details.msg-meta");
const context = page.locator(".msg-meta__details");
expect(await context.isVisible()).toBe(false);
await page.locator(".msg-meta__summary").hover();
expect(await context.isVisible()).toBe(true);
await page.mouse.move(0, 0);
expect(await context.isVisible()).toBe(false);
await page.locator(".msg-meta__summary").click();
await page.mouse.move(0, 0);
expect(await details.getAttribute("open")).toBe("");
expect(await context.isVisible()).toBe(true);
} finally {
await closeBrowserPage(page);
}
});
it.each([
[1120, 740],
[1366, 900],

View File

@@ -848,7 +848,12 @@ describe("grouped chat rendering", () => {
);
const meta = cached.querySelector<HTMLDetailsElement>("details.msg-meta");
expect(meta?.open).toBe(false);
expect(meta?.querySelector(".msg-meta__summary span:last-child")?.textContent).toBe("Context");
const summary = meta?.querySelector<HTMLElement>(".msg-meta__summary");
const time = summary?.querySelector<HTMLTimeElement>(".chat-group-timestamp");
expect(time).not.toBeNull();
expect(time?.title).toBe("");
expect(summary?.textContent).not.toContain("Context");
expect(summary?.getAttribute("aria-label")).toContain("Message context for");
expect(cached.querySelector(".msg-meta__ctx")?.textContent).toBe("44% ctx");
expect(
Array.from(cached.querySelectorAll(".msg-meta__cache")).map((node) => node.textContent),
@@ -866,6 +871,53 @@ describe("grouped chat rendering", () => {
expect(outputHeavy.querySelector(".msg-meta__ctx")?.textContent).toBe("10% ctx");
});
it("previews message context from the timestamp and pins it on click", () => {
const container = document.createElement("div");
renderAssistantMessage(
container,
{
role: "assistant",
content: "Done",
usage: { input: 12_000, output: 300 },
model: "openai/gpt-5.5",
timestamp: 1000,
},
{ contextWindow: 100_000 },
);
const details = container.querySelector<HTMLDetailsElement>("details.msg-meta")!;
const summary = details.querySelector<HTMLElement>("summary")!;
const pointerEnter = new Event("pointerenter");
Object.defineProperty(pointerEnter, "pointerType", { value: "mouse" });
details.dispatchEvent(pointerEnter);
expect(details.open).toBe(true);
details.dispatchEvent(new Event("pointerleave"));
expect(details.open).toBe(false);
details.dispatchEvent(pointerEnter);
summary.click();
details.dispatchEvent(new Event("pointerleave"));
expect(details.open).toBe(true);
summary.click();
expect(details.open).toBe(false);
});
it("keeps timestamps without context metadata non-interactive", () => {
const container = document.createElement("div");
renderAssistantMessage(container, {
role: "assistant",
content: "Done",
timestamp: 1000,
});
const time = container.querySelector<HTMLTimeElement>(".chat-group-timestamp");
expect(time).not.toBeNull();
expect(time?.closest("details.msg-meta")).toBeNull();
expect(time?.title).not.toBe("");
});
it("uses the largest single assistant call for grouped context usage", () => {
const container = document.createElement("div");

View File

@@ -117,15 +117,55 @@ export function formatChatTimestampForDisplay(timestamp: number): ChatTimestampD
};
}
function renderChatTimestamp(timestamp: number) {
function renderChatTimestamp(timestamp: number, interactive = false) {
const display = formatChatTimestampForDisplay(timestamp);
return html`
<time class="chat-group-timestamp" datetime=${display.dateTime} title=${display.title}>
<time
class="chat-group-timestamp"
datetime=${display.dateTime}
title=${interactive ? nothing : display.title}
>
${display.label}
</time>
`;
}
function resolveMessageMetaDetails(target: EventTarget | null): HTMLDetailsElement | null {
if (target instanceof HTMLDetailsElement) {
return target;
}
return target instanceof HTMLElement
? target.closest<HTMLDetailsElement>("details.msg-meta")
: null;
}
function previewMessageMeta(event: PointerEvent | FocusEvent) {
const details = resolveMessageMetaDetails(event.currentTarget);
if (!details || details.open || ("pointerType" in event && event.pointerType === "touch")) {
return;
}
details.dataset.preview = "true";
details.open = true;
}
function closeMessageMetaPreview(event: PointerEvent | FocusEvent) {
const details = resolveMessageMetaDetails(event.currentTarget);
if (!details || details.dataset.preview !== "true" || details.matches(":hover, :focus-within")) {
return;
}
delete details.dataset.preview;
details.open = false;
}
function pinMessageMetaPreview(event: MouseEvent) {
const details = resolveMessageMetaDetails(event.currentTarget);
if (details?.dataset.preview !== "true") {
return;
}
event.preventDefault();
delete details.dataset.preview;
}
export function resetAssistantAttachmentAvailabilityCacheForTest() {
assistantAttachmentAvailabilityCache.clear();
bumpAssistantAttachmentAvailabilityRenderVersion();
@@ -749,7 +789,7 @@ export function renderMessageGroup(group: MessageGroup, opts: RenderMessageGroup
)}
<div class="chat-group-footer">
<span class="chat-sender-name">${who}</span>
${renderChatTimestamp(group.timestamp)} ${renderMessageMeta(meta)}
${renderMessageMeta(group.timestamp, meta)}
${opts.onDelete
? renderDeleteButton(opts.onDelete, normalizedRole === "user" ? "left" : "right")
: nothing}
@@ -820,9 +860,9 @@ function extractGroupMeta(group: MessageGroup, contextWindow: number | null): Gr
return { input, output, cacheRead, cacheWrite, cost, model, contextPercent };
}
function renderMessageMeta(meta: GroupMeta | null) {
function renderMessageMeta(timestamp: number, meta: GroupMeta | null) {
if (!meta) {
return nothing;
return renderChatTimestamp(timestamp);
}
const parts: Array<ReturnType<typeof html>> = [];
@@ -874,14 +914,25 @@ function renderMessageMeta(meta: GroupMeta | null) {
}
if (parts.length === 0) {
return nothing;
return renderChatTimestamp(timestamp);
}
const display = formatChatTimestampForDisplay(timestamp);
return html`
<details class="msg-meta">
<summary class="msg-meta__summary">
<span class="msg-meta__summary-icon" aria-hidden="true">${icons.chevronRight}</span>
<span>Context</span>
<details
class="msg-meta"
@pointerenter=${previewMessageMeta}
@pointerleave=${closeMessageMetaPreview}
@focusin=${previewMessageMeta}
@focusout=${closeMessageMetaPreview}
>
<summary
class="msg-meta__summary"
aria-label=${`Message context for ${display.title}`}
@click=${pinMessageMetaPreview}
>
${renderChatTimestamp(timestamp, true)}
</summary>
<span class="msg-meta__details">${parts}</span>
</details>

View File

@@ -427,7 +427,7 @@ img.chat-avatar {
.msg-meta {
display: inline-flex;
align-items: center;
gap: 6px;
gap: 8px;
font-size: 12px; /* was 11px */
line-height: 1;
color: var(--muted);
@@ -438,53 +438,40 @@ img.chat-avatar {
list-style: none;
display: inline-flex;
align-items: center;
gap: 4px;
min-height: 22px;
padding: 2px 7px 2px 5px;
border: 1px solid var(--border);
border-radius: var(--radius-full);
background: color-mix(in srgb, var(--bg-hover, rgba(255, 255, 255, 0.08)) 65%, transparent);
padding: 4px 2px;
margin: -4px -2px;
border: 0;
border-radius: var(--radius-sm, 4px);
background: transparent;
color: inherit;
cursor: pointer;
user-select: none;
transition:
border-color var(--duration-fast) ease-out,
background var(--duration-fast) ease-out,
color var(--duration-fast) ease-out;
}
.msg-meta__summary::-webkit-details-marker {
display: none;
}
.msg-meta__summary:hover,
.msg-meta__summary:focus-visible {
border-color: color-mix(in srgb, var(--accent) 40%, var(--border));
background: var(--bg-hover, rgba(255, 255, 255, 0.08));
.msg-meta__summary .chat-group-timestamp {
transition:
color var(--duration-fast) ease-out,
opacity var(--duration-fast) ease-out,
text-decoration-color var(--duration-fast) ease-out;
text-decoration: underline dotted transparent;
text-underline-offset: 3px;
}
.msg-meta__summary:hover .chat-group-timestamp,
.msg-meta__summary:focus-visible .chat-group-timestamp,
.msg-meta[open] .msg-meta__summary .chat-group-timestamp {
color: var(--fg);
opacity: 0.9;
text-decoration-color: color-mix(in srgb, currentColor 45%, transparent);
}
.msg-meta__summary:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.msg-meta__summary-icon {
display: inline-flex;
width: 12px;
height: 12px;
transition: transform 120ms ease-out;
}
.msg-meta__summary-icon svg {
width: 12px;
height: 12px;
fill: none;
stroke: currentColor;
stroke-width: 2;
}
.msg-meta[open] .msg-meta__summary-icon {
transform: rotate(90deg);
outline-offset: 3px;
}
details.msg-meta:not([open]) .msg-meta__details {