mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-09 04:13:57 +00:00
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:
committed by
GitHub
parent
35272d01b5
commit
d84314d6b7
@@ -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.
|
||||
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user