fix(ui): remove resting last-active age from sidebar rows, surface it in the session menu (#110524)

This commit is contained in:
Peter Steinberger
2026-07-18 08:32:36 +01:00
committed by GitHub
parent 1ebfa066a5
commit 3abd321116
11 changed files with 52 additions and 14 deletions
@@ -68,6 +68,7 @@ export class SidebarCatalogMenuController {
.x=${menu.x}
.y=${menu.y}
.trigger=${this.trigger}
.lastActive=${menu.meta}
.terminalDisabled=${!menu.canOpenTerminal || !this.hooks.terminalAvailable()}
.onAction=${(action: CatalogSessionMenuAction) => this.handleAction(menu, action)}
.onClose=${() => this.close()}
+1
View File
@@ -405,6 +405,7 @@ export abstract class AppSidebarMenusElement extends AppSidebarSessionGroupsElem
category: batchRows ? sharedCategory : (session.category ?? null),
}}
.selectionCount=${rows.length}
.lastActive=${batchRows ? "" : session.meta}
.anchor=${menu}
.trigger=${this.sessionMenuTrigger}
.disabled=${!this.connected}
@@ -22,7 +22,6 @@ import {
import { searchForSession } from "../lib/sessions/index.ts";
import type { NewSessionTarget } from "../pages/new-session/location.ts";
import { shouldHandleNavigationClick } from "./app-sidebar-nav-menus.ts";
import { sidebarSessionMetaId } from "./app-sidebar-session-types.ts";
import { icons } from "./icons.ts";
export function formatSidebarTimestamp(timestampMs: number | null | undefined): string {
@@ -60,6 +59,7 @@ export type CatalogSessionMenuRequest = {
key: CatalogSessionKey;
search: string;
canOpenTerminal: boolean;
meta: string;
};
/** Stamps a freshly adopted session key onto its catalog row so the sidebar
@@ -363,7 +363,6 @@ function renderCatalogSessionRow(
const key = session.openClawSessionKey ?? buildCatalogSessionKey(catalogKey);
const label = session.name || session.threadId;
const meta = formatSidebarTimestamp(timestamp);
const metaId = meta ? sidebarSessionMetaId(key) : undefined;
const search = searchForSession(key);
const href = `${pathForRoute("chat", params.basePath)}${search}`;
const active = params.routeSessionKey !== "" && key === params.routeSessionKey;
@@ -371,7 +370,7 @@ function renderCatalogSessionRow(
const openTerminal = () => params.onOpenTerminal(catalogKey);
const openMenu = (x: number, y: number, trigger?: HTMLElement) =>
params.onOpenMenu(
{ key: catalogKey, search, canOpenTerminal: session.canOpenTerminal === true },
{ key: catalogKey, search, canOpenTerminal: session.canOpenTerminal === true, meta },
x,
y,
trigger,
@@ -393,7 +392,6 @@ function renderCatalogSessionRow(
class="sidebar-recent-session__link"
title=${`${label} · ${host.label}`}
aria-current=${active ? "page" : nothing}
aria-describedby=${metaId ?? nothing}
@click=${(event: MouseEvent) => {
if (!shouldHandleNavigationClick(event)) {
return;
@@ -411,7 +409,6 @@ function renderCatalogSessionRow(
</span>
</a>
<span class="sidebar-recent-session__aside session-row-aside">
<span class="session-row-trail" id=${metaId ?? nothing}>${meta}</span>
<span class="session-row-actions">
<button
class="session-action"
@@ -85,7 +85,9 @@ export abstract class AppSidebarSessionListElement extends AppSidebarMenusElemen
? session.subtitle
: undefined;
const meta = display?.meta ?? session.meta;
const metaId = meta ? sidebarSessionMetaId(session.key) : undefined;
const hasTrail = session.isChild && (session.runtimeMs != null || session.startedAt != null);
const metaId = hasTrail ? sidebarSessionMetaId(session.key) : undefined;
const menuSession = display ? { ...session, meta } : session;
const title = display?.title ?? [label, meta].filter(Boolean).join(" · ");
const rowClass = [
"sidebar-recent-session",
@@ -123,7 +125,7 @@ export abstract class AppSidebarSessionListElement extends AppSidebarMenusElemen
? nothing
: (event: MouseEvent) => {
event.preventDefault();
this.openSessionMenuForRow(session, event.clientX, event.clientY);
this.openSessionMenuForRow(menuSession, event.clientX, event.clientY);
}}
@mouseenter=${(event: MouseEvent) => startHoverMarquee(event.currentTarget as HTMLElement)}
@mouseleave=${(event: MouseEvent) => stopHoverMarquee(event.currentTarget as HTMLElement)}
@@ -189,7 +191,7 @@ export abstract class AppSidebarSessionListElement extends AppSidebarMenusElemen
.startMs=${session.startedAt}
.endMs=${session.endedAt ?? null}
></openclaw-elapsed-time>`
: meta}</span
: nothing}</span
>
${session.isChild
? nothing
@@ -225,7 +227,7 @@ export abstract class AppSidebarSessionListElement extends AppSidebarMenusElemen
}
const trigger = event.currentTarget as HTMLElement;
const rect = trigger.getBoundingClientRect();
this.openSessionMenuForRow(session, rect.right, rect.bottom + 4, trigger);
this.openSessionMenuForRow(menuSession, rect.right, rect.bottom + 4, trigger);
}}
>
${icons.moreHorizontal}
+8 -3
View File
@@ -710,6 +710,9 @@ describe("AppSidebar agent chip", () => {
| (HTMLElement & { startMs: number })
| null
)?.startMs;
const childTrail = childRows[0]?.querySelector<HTMLElement>(".session-row-trail");
expect(childTrail?.querySelector("openclaw-elapsed-time")).not.toBeNull();
expect(childRows[0]?.querySelector("a")?.getAttribute("aria-describedby")).toBe(childTrail?.id);
expect(runtimeStartMs).toBeGreaterThan(Date.now() - 31_000);
expect(runtimeStartMs).toBeLessThan(Date.now() - 29_000);
@@ -3490,9 +3493,9 @@ describe("AppSidebar session accessibility", () => {
expect(link?.querySelector(".sidebar-recent-session__name")?.textContent).toBe(
"Quarterly launch plan",
);
const descriptionId = link?.getAttribute("aria-describedby");
expect(descriptionId).toBeTruthy();
expect(descriptionId ? document.getElementById(descriptionId)?.textContent : "").toBe("now");
expect(link?.getAttribute("title")).toBe("Quarterly launch plan · now");
expect(link?.hasAttribute("aria-describedby")).toBe(false);
expect(row?.querySelector(".session-row-trail")?.textContent?.trim()).toBe("");
});
it("renders no chat rows when only the main session exists", async () => {
@@ -4308,6 +4311,8 @@ describe("AppSidebar catalog session rows", () => {
expect(active[0]?.getAttribute("role")).toBe("listitem");
expect(active[0]?.closest('[role="list"]')?.getAttribute("aria-label")).toBe("Local Codex");
expect(active[0]?.querySelector("a")?.getAttribute("aria-current")).toBe("page");
expect(active[0]?.querySelector("a")?.hasAttribute("aria-describedby")).toBe(false);
expect(active[0]?.querySelector(".session-row-trail")).toBeNull();
// The raw catalog key must not surface as a synthesized chat row.
// Catalogs nest inside the Coding zone, so classify each row by its
// closest section rather than any ancestor group.
@@ -21,7 +21,10 @@ describe("catalog session menu", () => {
const container = document.createElement("div");
containers.push(container);
document.body.append(container);
render(html`<openclaw-catalog-session-menu></openclaw-catalog-session-menu>`, container);
render(
html`<openclaw-catalog-session-menu .lastActive=${"57d"}></openclaw-catalog-session-menu>`,
container,
);
const menu = container.querySelector("openclaw-catalog-session-menu") as CatalogMenuElement;
await menu.updateComplete;
const dropdown = menu.querySelector<HTMLElement & { open: boolean }>("wa-dropdown");
@@ -31,6 +34,7 @@ describe("catalog session menu", () => {
expect(dropdown?.open).toBe(true);
expect(document.activeElement).toBe(items[0]);
expect(items.map((item) => item.getAttribute("value"))).toEqual(["viewer", "terminal"]);
expect(menu.querySelector(".session-menu__info")?.textContent?.trim()).toBe("Last active 57d");
});
it.each([
+7 -1
View File
@@ -12,6 +12,7 @@ class CatalogSessionMenu extends OpenClawLightDomElement {
@property({ attribute: false }) x = 0;
@property({ attribute: false }) y = 0;
@property({ attribute: false }) trigger: HTMLElement | null = null;
@property({ attribute: false }) lastActive = "";
@property({ attribute: false }) terminalDisabled = false;
@property({ attribute: false }) onAction: (action: CatalogSessionMenuAction) => void = () => {};
@property({ attribute: false }) onClose: () => void = () => {};
@@ -69,7 +70,7 @@ class CatalogSessionMenu extends OpenClawLightDomElement {
override render() {
const menuWidth = 240;
const menuMaxHeight = 112;
const menuMaxHeight = 140;
const x = Math.max(8, Math.min(this.x, window.innerWidth - menuWidth - 8));
const y = Math.max(8, Math.min(this.y, window.innerHeight - menuMaxHeight - 8));
const menuLabel = t("chat.catalog.sessionMenu");
@@ -91,6 +92,11 @@ class CatalogSessionMenu extends OpenClawLightDomElement {
aria-label=${menuLabel}
style="position: fixed; left: ${x}px; top: ${y}px; width: 1px; height: 1px; opacity: 0; pointer-events: none;"
></button>
${this.lastActive
? html`<div class="session-menu__info">
${t("sessionsView.lastActive", { time: this.lastActive })}
</div>`
: ""}
<wa-dropdown-item class="session-menu__item" value="viewer">
<span slot="icon" class="session-menu__icon" aria-hidden="true"
>${icons.messageSquare}</span
+9
View File
@@ -14,6 +14,7 @@ type SessionMenuData = {
};
type SessionMenuElement = HTMLElement & {
anchor: { x: number; y: number };
lastActive: string;
session: SessionMenuData;
updateComplete: Promise<boolean>;
};
@@ -36,6 +37,7 @@ async function mountMenu(
archiveAllowed?: boolean;
cloudWorkerStopAllowed?: boolean;
selectionCount?: number;
lastActive?: string;
groups?: readonly string[];
trigger?: HTMLElement | null;
onAction?: (action: SessionMenuAction) => void;
@@ -57,6 +59,7 @@ async function mountMenu(
html`<openclaw-session-menu
.session=${session}
.selectionCount=${options.selectionCount ?? 1}
.lastActive=${options.lastActive ?? "57d"}
.anchor=${{ x: 100, y: 100 }}
.trigger=${options.trigger ?? null}
.disabled=${false}
@@ -105,6 +108,12 @@ function menuItem(menu: ParentNode, label: string): SessionMenuItem {
}
describe("session menu", () => {
it("shows when the session was last active", async () => {
const menu = await mountMenu({ lastActive: "57d" });
expect(menu.querySelector(".session-menu__info")?.textContent?.trim()).toBe("Last active 57d");
});
it("renders the full plain-session item set in order", async () => {
const menu = await mountMenu();
+6
View File
@@ -58,6 +58,7 @@ class SessionMenu extends OpenClawLightDomElement {
// session (unread/group/archive/delete); `session` then carries aggregated
// flags (unread = all unread, category = shared category or null).
@property({ attribute: false }) selectionCount = 1;
@property({ attribute: false }) lastActive = "";
@property({ attribute: false }) anchor: { x: number; y: number } = { x: 0, y: 0 };
@property({ attribute: false }) trigger: HTMLElement | null = null;
@property({ attribute: false }) disabled = false;
@@ -273,6 +274,11 @@ class SessionMenu extends OpenClawLightDomElement {
aria-label=${menuLabel}
style="position: fixed; left: ${clampedX}px; top: ${clampedY}px; width: 1px; height: 1px; opacity: 0; pointer-events: none;"
></button>
${!batch && this.lastActive
? html`<div class="session-menu__info">
${t("sessionsView.lastActive", { time: this.lastActive })}
</div>`
: nothing}
${!batch && this.canOpenChat
? html`
<wa-dropdown-item
+1
View File
@@ -615,6 +615,7 @@ export const en: TranslationMap = {
provider: "Provider",
runtime: "Runtime",
runDuration: "Run duration",
lastActive: "Last active {time}",
surface: "Surface",
subject: "Subject",
room: "Room",
+6
View File
@@ -2178,6 +2178,12 @@ wa-dropdown.sidebar-session-sort-menu::part(menu) {
letter-spacing: 0.04em;
}
.session-menu__info {
padding: 4px 8px 6px;
color: var(--muted);
font-size: 11px;
}
.session-menu__separator {
margin: 6px 4px;
border-top: 1px solid color-mix(in srgb, var(--border) 80%, transparent);