diff --git a/CHANGELOG.md b/CHANGELOG.md index 433005bc1519..1018b49ed7e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/ui/src/pages/chat/chat-responsive.browser.test.ts b/ui/src/pages/chat/chat-responsive.browser.test.ts index 8a2daf3e6b9b..6b6b1e512974 100644 --- a/ui/src/pages/chat/chat-responsive.browser.test.ts +++ b/ui/src/pages/chat/chat-responsive.browser.test.ts @@ -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], diff --git a/ui/src/pages/chat/components/chat-message.test.ts b/ui/src/pages/chat/components/chat-message.test.ts index 40b5e2a59656..5b44f51d8d0a 100644 --- a/ui/src/pages/chat/components/chat-message.test.ts +++ b/ui/src/pages/chat/components/chat-message.test.ts @@ -848,7 +848,12 @@ describe("grouped chat rendering", () => { ); const meta = cached.querySelector("details.msg-meta"); expect(meta?.open).toBe(false); - expect(meta?.querySelector(".msg-meta__summary span:last-child")?.textContent).toBe("Context"); + const summary = meta?.querySelector(".msg-meta__summary"); + const time = summary?.querySelector(".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("details.msg-meta")!; + const summary = details.querySelector("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(".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"); diff --git a/ui/src/pages/chat/components/chat-message.ts b/ui/src/pages/chat/components/chat-message.ts index 2fa53a38ea04..5ece7991e344 100644 --- a/ui/src/pages/chat/components/chat-message.ts +++ b/ui/src/pages/chat/components/chat-message.ts @@ -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` -