refactor(ui): simplify sidebar hover stability (#128973)

* refactor(ui): simplify sidebar hover lifecycle

* refactor(ui): reduce sidebar hover machinery

* fix(ui): preserve sidebar row lifecycle invariants

* fix(ui): refresh adopted row marquee badges

* fix(ui): preserve collapsed project sections

* fix(ui): isolate catalog collapse identities

* fix(ui): remeasure resized session marquees

* fix(ui): migrate legacy catalog collapse groups

* fix(ui): keep trailing glyphs inside sidebar endcap
This commit is contained in:
Jesse Merhi
2026-08-26 14:22:03 +10:00
committed by GitHub
parent ceb074ef46
commit e9dfe8a71d
22 changed files with 756 additions and 1721 deletions
+1 -1
View File
@@ -3980,7 +3980,7 @@ ui/src/components/app-sidebar-session-catalog-render.ts 4
ui/src/components/app-sidebar-session-list-render.ts 2
ui/src/components/app-sidebar-session-menu-renderers.ts 4
ui/src/components/app-sidebar-session-narration.ts 5
ui/src/components/app-sidebar-session-row-render.ts 4
ui/src/components/app-sidebar-session-row-render.ts 2
ui/src/components/app-sidebar-session-section-header.ts 6
ui/src/components/app-sidebar.ts 2
ui/src/components/app-topbar.ts 1
@@ -32,27 +32,4 @@ describe("SidebarCatalogMenuController", () => {
expect(order).toEqual(["dismiss", "open"]);
});
it("does not schedule trigger retargeting while the menu is closed", () => {
const controller = new SidebarCatalogMenuController({
beforeOpen: vi.fn(),
requestUpdate: vi.fn(),
terminalAvailable: () => true,
navigate: vi.fn(),
});
const trigger = document.createElement("button");
document.body.append(trigger);
const queueMicrotaskSpy = vi.spyOn(globalThis, "queueMicrotask");
try {
controller.retargetTrigger(
{ catalogId: "codex", hostId: "gateway:local", threadId: "thread-1" },
trigger,
);
expect(queueMicrotaskSpy).not.toHaveBeenCalled();
} finally {
queueMicrotaskSpy.mockRestore();
trigger.remove();
}
});
});
@@ -63,14 +63,12 @@ export class SidebarCatalogMenuController {
if (!(element instanceof HTMLElement) || !this.isOpenFor(key)) {
return;
}
// A catalog refresh can replace the owning row while popup focus is elsewhere.
// Retarget only after the old trigger disconnects so dismissal has a live focus anchor.
// Catalog adoption replaces the trigger while popup focus is elsewhere.
queueMicrotask(() => {
if (!element.isConnected || this.trigger?.isConnected || !this.isOpenFor(key)) {
return;
if (element.isConnected && !this.trigger?.isConnected && this.isOpenFor(key)) {
this.trigger = element;
this.hooks.requestUpdate();
}
this.trigger = element;
this.hooks.requestUpdate();
});
}
@@ -89,6 +89,51 @@ type SessionCatalogGroupsParams = {
isMenuOpen: (key: CatalogSessionKey) => boolean;
};
const CATALOG_CONTROL_SELECTORS = [
".sidebar-recent-session__link",
"[data-child-session-toggle]",
"[data-sidebar-session-pin]",
"[data-catalog-session-menu], [data-session-menu]",
] as const;
function catalogRowRef(
identityKey: string,
sessionKey: string,
catalogKey: CatalogSessionKey,
menuOpen: boolean,
params: SessionCatalogGroupsParams,
): ((element: Element | undefined) => void) | undefined {
const active = document.activeElement instanceof HTMLElement ? document.activeElement : null;
const activeRow = active?.closest<HTMLElement>("[data-session-key]");
const selector = CATALOG_CONTROL_SELECTORS.find((candidate) => active?.matches(candidate));
const restoreFocus =
selector !== undefined &&
(activeRow?.dataset.catalogSessionKey === identityKey ||
activeRow?.dataset.sessionKey === identityKey ||
activeRow?.dataset.sessionKey === sessionKey);
if (!menuOpen && !restoreFocus) {
return undefined;
}
return (element) => {
if (!(element instanceof HTMLElement)) {
return;
}
if (menuOpen) {
params.onCatalogMenuTriggerRendered(
catalogKey,
element.querySelector(CATALOG_CONTROL_SELECTORS[3]) ?? undefined,
);
}
if (restoreFocus) {
queueMicrotask(() => {
if (element.isConnected && document.activeElement === document.body) {
element.querySelector<HTMLElement>(selector)?.focus({ preventScroll: true });
}
});
}
};
}
function renderSessionRunSpinner(showTitle = true) {
return html`<span
class="session-run-spinner"
@@ -306,47 +351,6 @@ export function renderSessionCatalogGroups(params: SessionCatalogGroupsParams) {
export type SessionCatalogGroupsRenderer = typeof renderSessionCatalogGroups;
function catalogSessionIdentityKey(
catalog: SessionCatalog,
host: SessionCatalogHost,
session: SessionCatalogSession,
): string {
return buildCatalogSessionKey({
catalogId: catalog.id,
hostId: host.hostId,
threadId: session.threadId,
});
}
function renderCatalogSessionRows(
catalog: SessionCatalog,
host: SessionCatalogHost,
sessions: readonly SessionCatalogSession[],
liveRowsByKey: ReadonlyMap<string, GatewaySessionRow>,
params: SessionCatalogGroupsParams,
projectChild = false,
) {
return repeat(
sessions,
(session) => catalogSessionIdentityKey(catalog, host, session),
(session) =>
renderCatalogSessionRow(catalog, host, session, liveRowsByKey, params, projectChild),
);
}
function restoreCatalogControlFocus(element: Element | undefined): void {
if (!(element instanceof HTMLAnchorElement || element instanceof HTMLButtonElement)) {
return;
}
// Reordering moves the keyed row through a disconnected state. Restore only
// the focus that movement dropped; never override a newer user focus choice.
queueMicrotask(() => {
if (element.isConnected && document.activeElement === document.body) {
element.focus({ preventScroll: true });
}
});
}
function renderCatalogHostGroup(
catalog: SessionCatalog,
host: SessionCatalogHost,
@@ -362,6 +366,18 @@ function renderCatalogHostGroup(
: params.projectGrouping === "person"
? groupCatalogSessionsByPerson(host.sessions)
: null;
const renderRows = (sessions: readonly SessionCatalogSession[], projectChild = false) =>
repeat(
sessions,
(session) =>
buildCatalogSessionKey({
catalogId: catalog.id,
hostId: host.hostId,
threadId: session.threadId,
}),
(session) =>
renderCatalogSessionRow(catalog, host, session, liveRowsByKey, params, projectChild),
);
// Gateway errors stay on the catalog header; node headings remain so remote rows keep their owner.
const showHostHeading = host.kind !== "gateway";
return html`
@@ -391,8 +407,16 @@ function renderCatalogHostGroup(
projectGroups.groups,
(group) => group.key,
(group) => {
const sectionId = `catalog-project:${catalog.id}:${host.hostId}:${group.key}`;
const collapsed = params.collapsedSections.has(sectionId);
const sectionId = `catalog-${group.kind}:${catalog.id}:${host.hostId}:${group.key}`;
const legacySectionId = group.legacySectionKey
? `catalog-project:${catalog.id}:${host.hostId}:${group.legacySectionKey}`
: null;
const collapsedSectionId = params.collapsedSections.has(sectionId)
? sectionId
: legacySectionId && params.collapsedSections.has(legacySectionId)
? legacySectionId
: null;
const collapsed = collapsedSectionId !== null;
return html`
<div class="sidebar-session-catalog-project" role="listitem">
<button
@@ -401,7 +425,7 @@ function renderCatalogHostGroup(
data-session-catalog-project=${group.key}
aria-expanded=${String(!collapsed)}
title=${group.title}
@click=${() => params.onToggleSection(sectionId)}
@click=${() => params.onToggleSection(collapsedSectionId ?? sectionId)}
>
<span class="sidebar-session-catalog-project__icon" aria-hidden="true"
>${collapsed ? icons.chevronRight : icons.chevronDown}</span
@@ -418,27 +442,14 @@ function renderCatalogHostGroup(
role="list"
aria-label=${`${host.label}: ${group.label}`}
>
${renderCatalogSessionRows(
catalog,
host,
group.sessions,
liveRowsByKey,
params,
true,
)}
${renderRows(group.sessions, true)}
</div>`}
</div>
`;
},
)}
${renderCatalogSessionRows(
catalog,
host,
projectGroups.ungrouped,
liveRowsByKey,
params,
)}`
: renderCatalogSessionRows(catalog, host, host.sessions, liveRowsByKey, params)}
${renderRows(projectGroups.ungrouped)}`
: renderRows(host.sessions)}
</div>
</section>
`;
@@ -460,47 +471,22 @@ function renderCatalogSessionRow(
hostId: host.hostId,
threadId: session.threadId,
} satisfies CatalogSessionKey;
const catalogMenuOpen = params.isMenuOpen(catalogKey);
const catalogMenuTriggerRef = catalogMenuOpen
? (element: Element | undefined) => params.onCatalogMenuTriggerRendered(catalogKey, element)
: undefined;
const identityKey = catalogSessionIdentityKey(catalog, host, session);
const identityKey = buildCatalogSessionKey(catalogKey);
const key = session.sessionKey ?? identityKey;
const focusedControl =
document.activeElement instanceof HTMLElement ? document.activeElement : undefined;
const focusedRow = focusedControl?.closest<HTMLElement>("[data-session-key]");
const restoreFocusedControl =
focusedRow?.dataset.catalogSessionKey === identityKey ||
focusedRow?.dataset.sessionKey === identityKey ||
focusedRow?.dataset.sessionKey === key;
const focusedControlKind = focusedControl?.matches(".sidebar-recent-session__link")
? "link"
: focusedControl?.matches("[data-child-session-toggle]")
? "child-toggle"
: focusedControl?.matches("[data-sidebar-session-pin]")
? "pin"
: focusedControl?.matches("[data-catalog-session-menu], [data-session-menu]")
? "menu"
: undefined;
const focusRef =
restoreFocusedControl && focusedControlKind
? (element: Element | undefined) => restoreCatalogControlFocus(element)
: undefined;
const label = session.name || session.threadId;
const menuOpen = params.isMenuOpen(catalogKey);
const rowRef = catalogRowRef(identityKey, key, catalogKey, menuOpen, params);
const adoptedRow = session.sessionKey ? liveRowsByKey.get(session.sessionKey) : undefined;
if (adoptedRow) {
const label = session.name || session.threadId;
return params.renderLiveRow(adoptedRow, {
label,
catalogIdentityKey: identityKey,
marqueeKey: JSON.stringify([label, session.pullRequest]),
catalogMenuOpen,
...(catalogMenuTriggerRef ? { catalogMenuTriggerRef } : {}),
catalogMenuOpen: menuOpen,
...(rowRef ? { rowRef } : {}),
...(session.pullRequest ? { pullRequest: session.pullRequest } : {}),
...(focusRef && focusedControlKind
? { focusedControl: focusedControlKind, restoreControlFocus: focusRef }
: {}),
});
}
const label = session.name || session.threadId;
const meta = formatSidebarTimestamp(timestamp);
const routeId = "chat";
const target = sessionNavigationTarget({
@@ -539,7 +525,6 @@ function renderCatalogSessionRow(
: null,
(trigger, x, y) => openMenu(x, y, trigger ?? undefined),
);
// Marquee state lives on the label; reset it without replacing focused row controls.
const marqueeLabel = keyed(
JSON.stringify([label, session.status, session.pullRequest]),
html`<span
@@ -548,8 +533,9 @@ function renderCatalogSessionRow(
>${label}</span
>`,
);
const row = html`
return html`
<div
${rowRef ? ref(rowRef) : nothing}
class="sidebar-recent-session session-row-host sidebar-recent-session--single-line ${active
? "sidebar-recent-session--active"
: ""} ${projectChild ? "sidebar-recent-session--catalog-project-child" : ""} ${running
@@ -565,7 +551,6 @@ function renderCatalogSessionRow(
@mouseleave=${stopHoverMarqueeFromEvent}
>
<a
${focusedControlKind === "link" && focusRef ? ref(focusRef) : nothing}
href=${withSidebarNavCollapseIntent(href)}
class="sidebar-recent-session__link"
aria-current=${active ? "page" : nothing}
@@ -609,15 +594,13 @@ function renderCatalogSessionRow(
<span class="sidebar-recent-session__aside session-row-aside">
<span class="session-row-actions">
<button
${catalogMenuTriggerRef ? ref(catalogMenuTriggerRef) : nothing}
${focusedControlKind === "menu" && focusRef ? ref(focusRef) : nothing}
class="session-action"
data-catalog-session-menu="true"
type="button"
title=${t("chat.sidebar.openSessionMenu")}
aria-label=${t("chat.sidebar.openSessionMenu")}
aria-haspopup="menu"
aria-expanded=${String(catalogMenuOpen)}
aria-expanded=${String(menuOpen)}
@click=${(event: MouseEvent) => {
event.stopPropagation();
const trigger = event.currentTarget as HTMLElement;
@@ -631,5 +614,4 @@ function renderCatalogSessionRow(
</span>
</div>
`;
return row;
}
@@ -135,13 +135,10 @@ export function visibleCatalogHosts(
export type CatalogBackingSessionDisplay = {
label: string;
catalogIdentityKey: string;
marqueeKey?: string;
catalogMenuOpen?: boolean;
catalogMenuTriggerRef?: (element: Element | undefined) => void;
catalogMenuOpen: boolean;
rowRef?: (element: Element | undefined) => void;
subtitle?: string;
pullRequest?: SessionCatalogSession["pullRequest"];
focusedControl?: "link" | "child-toggle" | "pin" | "menu";
restoreControlFocus?: (element: Element | undefined) => void;
};
export type CatalogSessionMenuRequest = {
@@ -14,8 +14,8 @@ import { sessionHasBoard } from "../lib/board/provider.ts";
import { formatDurationCompact } from "../lib/format.ts";
import {
restartHoverMarqueeIfHovered,
startHoverMarquee,
stopHoverMarquee,
startHoverMarqueeFromEvent,
stopHoverMarqueeFromEvent,
} from "../lib/hover-marquee.ts";
import { handleContextMenuEvent } from "../lib/keyboard-shortcuts.ts";
import { projectPresencePayload } from "../lib/presence-users.ts";
@@ -196,18 +196,12 @@ export function renderRecentSession(params: {
: session.owner?.actor
: undefined;
const ownerId = ownerActor?.id?.trim();
const presenceProjection =
ownerId || display?.marqueeKey
? projectPresencePayload(
host.sessionData.presencePayload,
host.sessionDataContext?.gateway.snapshot.selfUser?.id,
host.sessionData.presenceInstanceId,
)
: undefined;
const ownerViewing = ownerId
? presenceProjection?.users.some(
(user) => user.id === ownerId && user.watchedSessions.includes(session.key),
)
? projectPresencePayload(
host.sessionData.presencePayload,
host.sessionDataContext?.gateway.snapshot.selfUser?.id,
host.sessionData.presenceInstanceId,
).users.some((user) => user.id === ownerId && user.watchedSessions.includes(session.key))
: undefined;
const gateway = host.sessionDataContext?.gateway;
const channelAvatarAuth = {
@@ -244,20 +238,6 @@ export function renderRecentSession(params: {
const hasTrail = session.isChild && (session.runtimeMs != null || session.startedAt != null);
const metaId = hasTrail ? sidebarSessionMetaId(session.key) : undefined;
const stateId = trailingDescription ? sidebarSessionStateId(session.key) : undefined;
const hasBoard = !session.isChild && sessionHasBoard(session.key);
const pullRequest = session.pullRequest ?? display?.pullRequest;
const hasApproval = sessionHasPendingApproval(
host.sessionData.approvalBadgeSnapshot(),
session.key,
);
const visibleViewerCount = display?.marqueeKey
? (presenceProjection?.users.filter(
(user) =>
user.id !== presenceProjection.selfUserId &&
user.id !== renderedOwnerId &&
user.watchedSessions.includes(session.key),
).length ?? 0)
: 0;
const openMenuFromEvent = (event: MouseEvent | KeyboardEvent) =>
handleContextMenuEvent(
event,
@@ -302,35 +282,8 @@ export function renderRecentSession(params: {
requiredScope: "operator.write",
});
const rowDraggable = !session.isChild && groupWriteAccess.allowed;
// Adopted catalog rows keep their live row node, so replace only the label
// whenever live title/endcap geometry changes and remeasure under the pointer.
const marqueeKey = display?.marqueeKey
? JSON.stringify([
display.marqueeKey,
session.archived === true,
session.forkSource !== undefined,
subtitle ?? null,
session.childSessionKeys.length,
hasBoard,
Math.min(visibleViewerCount, 4),
session.incognito === true,
session.hasAutomation,
pullRequest ?? null,
hasApproval,
session.outboxAttentionCount ?? 0,
session.hasComposerDraft === true,
session.placementState ?? null,
session.diskSpaceStatus ?? null,
session.workspaceConflictCount ?? 0,
pullRequestState,
running,
session.status ?? null,
session.unread,
hasTrail,
])
: undefined;
const marqueeLabelTemplate = html`<span
${marqueeKey ? ref(restartHoverMarqueeIfHovered) : nothing}
${display ? ref(restartHoverMarqueeIfHovered) : nothing}
class="sidebar-recent-session__name hover-marquee"
>${session.archived
? html`<span
@@ -348,10 +301,21 @@ export function renderRecentSession(params: {
>`
: nothing}${label}</span
>`;
const marqueeLabel = marqueeKey ? keyed(marqueeKey, marqueeLabelTemplate) : marqueeLabelTemplate;
const marqueeLabel = display
? keyed(
JSON.stringify([
label,
session.archived === true,
session.forkSource !== undefined,
session.pullRequest ?? display.pullRequest,
]),
marqueeLabelTemplate,
)
: marqueeLabelTemplate;
// Always reserve the lead so every title shares the section-label text line.
const row = html`
<div
${display?.rowRef ? ref(display.rowRef) : nothing}
class=${rowClass}
data-session-key=${session.key}
data-catalog-session-key=${display?.catalogIdentityKey ?? nothing}
@@ -372,13 +336,10 @@ export function renderRecentSession(params: {
}}
@contextmenu=${openMenuFromEvent}
@keydown=${openMenuFromEvent}
@mouseenter=${(event: MouseEvent) => startHoverMarquee(event.currentTarget as HTMLElement)}
@mouseleave=${(event: MouseEvent) => stopHoverMarquee(event.currentTarget as HTMLElement)}
@mouseenter=${startHoverMarqueeFromEvent}
@mouseleave=${stopHoverMarqueeFromEvent}
>
<a
${display?.focusedControl === "link" && display.restoreControlFocus
? ref(display.restoreControlFocus)
: nothing}
href=${withSidebarNavCollapseIntent(session.href)}
class="sidebar-recent-session__link"
draggable="false"
@@ -399,7 +360,7 @@ export function renderRecentSession(params: {
<span class="sidebar-recent-session__details">
${renderSidebarSessionSubtitle({ subtitle, narration })}
<span class="sidebar-recent-session__details-endcap">
${hasBoard
${!session.isChild && sessionHasBoard(session.key)
? html`<span
class="sidebar-board-glyph"
role="img"
@@ -420,8 +381,11 @@ export function renderRecentSession(params: {
${renderSessionRowBadges({
...session,
hasComposerDraft: session.hasComposerDraft === true,
pullRequest,
hasApproval,
pullRequest: session.pullRequest ?? display?.pullRequest,
hasApproval: sessionHasPendingApproval(
host.sessionData.approvalBadgeSnapshot(),
session.key,
),
})}
${trailingIndicator === nothing
? trailingDescription
@@ -456,9 +420,6 @@ export function renderRecentSession(params: {
</a>
${session.childSessionKeys.length > 0
? html`<button
${display?.focusedControl === "child-toggle" && display.restoreControlFocus
? ref(display.restoreControlFocus)
: nothing}
class="sidebar-child-session-toggle ${session.runningChildCount > 0
? "sidebar-child-session-toggle--running"
: session.failedChildCount > 0
@@ -490,9 +451,6 @@ export function renderRecentSession(params: {
${session.isChild
? nothing
: html`<button
${display?.focusedControl === "pin" && display.restoreControlFocus
? ref(display.restoreControlFocus)
: nothing}
class="session-action session-action--pin"
data-sidebar-session-pin="true"
type="button"
@@ -505,10 +463,6 @@ export function renderRecentSession(params: {
</button>`}
<openclaw-tooltip .content=${menuTooltip} .describe=${false} .disabled=${menuOpen}>
<button
${display?.catalogMenuTriggerRef ? ref(display.catalogMenuTriggerRef) : nothing}
${display?.focusedControl === "menu" && display.restoreControlFocus
? ref(display.restoreControlFocus)
: nothing}
class="session-action"
data-session-menu="true"
type="button"
+1 -1
View File
@@ -8,8 +8,8 @@ import "../test-helpers/app-sidebar-cases/footer-status.ts";
import "../test-helpers/app-sidebar-cases/catalog-compat.ts";
import "../test-helpers/app-sidebar-cases/catalog-live-events.ts";
import "../test-helpers/app-sidebar-cases/catalog-project-activity.ts";
import "../test-helpers/app-sidebar-cases/catalog-row-lifecycle.ts";
import "../test-helpers/app-sidebar-cases/catalog-live.ts";
import "../test-helpers/app-sidebar-cases/catalog-row-keying.ts";
import "../test-helpers/app-sidebar-cases/catalog-reconnect.ts";
import "../test-helpers/app-sidebar-cases/catalog-live-errors.ts";
import "../test-helpers/app-sidebar-cases/catalog-live-state.ts";
@@ -487,10 +487,6 @@ describeBrowserLayout("app chrome interaction styles", () => {
<span class="sidebar-recent-session__name">Child session</span>
<span class="session-row-trail">3m</span>
</div>
<div class="sidebar-recent-session">
<a class="sidebar-recent-session__link">Parent session</a>
<button class="sidebar-child-session-toggle" aria-expanded="false">2</button>
</div>
</div>
</body>
</html>
@@ -524,11 +520,6 @@ describeBrowserLayout("app chrome interaction styles", () => {
settingsSearch: 16,
navItem: 12,
});
const childToggleSize = await page.$eval(".sidebar-child-session-toggle", (node) => ({
height: node.getBoundingClientRect().height,
width: node.getBoundingClientRect().width,
}));
expect(childToggleSize).toEqual({ height: 44, width: 44 });
await page.evaluate(() => {
document.documentElement.style.setProperty("--control-ui-text-scale", "1.4");
@@ -237,7 +237,7 @@ suite.define(() => {
const sessions = chatSessionListResponse();
const firstSession = expectDefined(sessions.sessions[0], "first chat session fixture");
const secondSession = expectDefined(sessions.sessions[1], "second chat session fixture");
firstSession.label = "Title fits until actions appear";
firstSession.label = "Short";
secondSession.label =
"Review and repair the intentionally overlong sidebar session title before navigation ".repeat(
4,
@@ -263,51 +263,10 @@ suite.define(() => {
}));
expect(layout.scrollWidth, JSON.stringify(layout)).toBeGreaterThan(layout.clientWidth);
const hoverOnlyRow = page.locator(
'.sidebar-recent-session[data-session-key="agent:main:session-a"]',
);
const hoverOnlyLabel = hoverOnlyRow.locator(".sidebar-recent-session__name");
const restingHoverOnlyLayout = await hoverOnlyLabel.evaluate((label) => {
const viewport = label.parentElement as HTMLElement;
const style = getComputedStyle(viewport);
return {
scrollWidth: label.scrollWidth,
viewportWidth:
viewport.clientWidth -
(Number.parseFloat(style.paddingLeft) || 0) -
(Number.parseFloat(style.paddingRight) || 0),
};
});
expect(
restingHoverOnlyLayout.scrollWidth,
JSON.stringify(restingHoverOnlyLayout),
).toBeLessThanOrEqual(restingHoverOnlyLayout.viewportWidth);
await hoverOnlyRow.hover();
const hoveredHoverOnlyLayout = await hoverOnlyLabel.evaluate((label) => {
const viewport = label.parentElement as HTMLElement;
const style = getComputedStyle(viewport);
return {
scrollWidth: label.scrollWidth,
viewportWidth:
viewport.clientWidth -
(Number.parseFloat(style.paddingLeft) || 0) -
(Number.parseFloat(style.paddingRight) || 0),
};
});
expect(
hoveredHoverOnlyLayout.scrollWidth,
JSON.stringify(hoveredHoverOnlyLayout),
).toBeGreaterThan(hoveredHoverOnlyLayout.viewportWidth);
await expect
.poll(() => hoverOnlyLabel.evaluate((label) => label.classList.value), { timeout: 1_500 })
.toContain("hover-marquee--scrolling");
await page.mouse.move(0, 0);
// Freeze the clock so the 500ms hover-intent delay elapses only via
// runFor; a ticking clock let slow runners start the marquee before the
// "not yet scrolling" asserts below.
await pauseVirtualClock(page);
await recentRow.dispatchEvent("mouseenter");
await page.clock.runFor(250);
expect(await recentLabel.evaluate((label) => label.classList.value)).not.toContain(
@@ -320,7 +279,7 @@ suite.define(() => {
"hover-marquee--scrolling",
);
await recentRow.dispatchEvent("mouseenter");
await page.clock.runFor(520);
await page.clock.runFor(500);
await expect
.poll(() => recentLabel.evaluate((label) => label.classList.value), { timeout: 1_500 })
.toContain("hover-marquee--scrolling");
@@ -424,6 +383,9 @@ suite.define(() => {
try {
await page.goto(`${suite.server.baseUrl}chat`);
// Rotation expands the spinner element's square DOMRect even though its
// circular ink is unchanged; freeze it while asserting endcap geometry.
await page.addStyleTag({ content: ".session-run-spinner { animation: none !important; }" });
const busyRow = page.locator(`.sidebar-recent-session[data-session-key="${busyKey}"]`);
const plainRow = page.locator(`.sidebar-recent-session[data-session-key="${plainKey}"]`);
await busyRow.locator(".session-row-badges").waitFor();
@@ -478,12 +440,7 @@ suite.define(() => {
// beneath it. Only rows that actually have a subtitle keep the two-line shape.
expect(plain.singleLine).toBe(true);
expect(plain.height).toBeLessThan(layout.busyHeight);
// Badges belong to the second line. Comparing centres keeps this true
// whatever size the row's glyphs are; the old top-edge slack was
// calibrated to one particular glyph size.
expect((layout.badges.top + layout.badges.bottom) / 2).toBeGreaterThan(
(layout.name.top + layout.name.bottom) / 2,
);
expect(layout.badges.top).toBeGreaterThanOrEqual(layout.name.bottom - 1);
expect(layout.name.right).toBeGreaterThan(layout.badges.left);
expect((layout.badges.top + layout.badges.bottom) / 2).toBeCloseTo(
(layout.subtitle.top + layout.subtitle.bottom) / 2,
@@ -543,8 +500,6 @@ suite.define(() => {
.locator(".sidebar-recent-session__details-endcap")
.evaluate((element) => getComputedStyle(element).opacity),
)
// The actions sit on the title line now, so the second line keeps its
// status icons instead of trading them for the buttons on hover.
.toBe("1");
await expect
.poll(() =>
@@ -635,404 +590,4 @@ suite.define(() => {
await suite.closeBrowserContext(context);
}
});
it("preserves adopted-row focus across repeated catalog reorders", async () => {
const context = await suite.newBrowserContext({});
const page = await context.newPage();
const firstKey = "agent:main:first-adopted";
const secondKey = "agent:main:second-adopted";
const catalogResponse = (order: ReadonlyArray<readonly [string, string]>) => ({
catalogs: [
{
id: "codex",
label: "Codex",
capabilities: { continueSession: true, archive: true },
hosts: [
{
hostId: "gateway:local",
label: "Local Codex",
kind: "gateway",
connected: true,
sessions: order.map(([threadId, sessionKey]) => ({
threadId,
sessionKey,
name: threadId,
status: "idle",
archived: false,
canContinue: true,
canArchive: true,
})),
},
],
},
],
});
const initialOrder = [
["First adopted", firstKey],
["Second adopted", secondKey],
] as const;
const reversedOrder = initialOrder.toReversed();
const gateway = await installMockGateway(page, {
featureMethods: ["chat.metadata", "chat.startup", "sessions.catalog.list", "sessions.patch"],
methodResponses: {
"sessions.list": chatSessionListResponse([
{
key: firstKey,
kind: "direct",
label: "First adopted",
updatedAt: 2,
childSessions: ["agent:main:child"],
},
{ key: secondKey, kind: "direct", label: "Second adopted", updatedAt: 1 },
]),
"sessions.catalog.list": catalogResponse(initialOrder),
},
});
try {
await page.goto(`${suite.server.baseUrl}chat`);
const catalog = page.locator('[data-session-section="catalog:codex"]');
await catalog.waitFor({ state: "visible" });
const toggle = catalog.locator(".sidebar-session-group-toggle");
if ((await toggle.getAttribute("aria-expanded")) === "false") {
await toggle.click();
}
const rows = catalog.locator(".sidebar-session-catalog-host__sessions > [data-session-key]");
for (const [control, selector] of [
["link", ".sidebar-recent-session__link"],
["child-toggle", "[data-child-session-toggle]"],
["pin", "[data-sidebar-session-pin]"],
["menu", "[data-session-menu]"],
] as const) {
const requestCount = (await gateway.getRequests("sessions.catalog.list")).length;
await gateway.setMethodResponse("sessions.catalog.list", catalogResponse(initialOrder));
await page.evaluate(() => window.dispatchEvent(new Event("focus")));
await expect
.poll(async () => (await gateway.getRequests("sessions.catalog.list")).length)
.toBeGreaterThan(requestCount);
await expect
.poll(() => rows.evaluateAll((elements) => elements.map((row) => row.dataset.sessionKey)))
.toEqual(initialOrder.map(([, sessionKey]) => sessionKey));
const focusedControl = catalog.locator(`[data-session-key="${firstKey}"] ${selector}`);
await focusedControl.focus();
await focusedControl.evaluate((element, value) => {
element.setAttribute("data-focus-probe", value);
}, control);
for (const order of [reversedOrder, initialOrder, reversedOrder]) {
const reorderRequestCount = (await gateway.getRequests("sessions.catalog.list")).length;
await gateway.setMethodResponse("sessions.catalog.list", catalogResponse(order));
await page.evaluate(() => window.dispatchEvent(new Event("focus")));
await expect
.poll(async () => (await gateway.getRequests("sessions.catalog.list")).length)
.toBeGreaterThan(reorderRequestCount);
await expect
.poll(() =>
rows.evaluateAll((elements) => elements.map((row) => row.dataset.sessionKey)),
)
.toEqual(order.map(([, sessionKey]) => sessionKey));
expect(await focusedControl.getAttribute("data-focus-probe")).toBe(control);
await expect
.poll(() => focusedControl.evaluate((element) => element === document.activeElement))
.toBe(true);
}
}
} finally {
await suite.closeBrowserContext(context);
}
});
it("resets an adopted catalog marquee when its label becomes short", async () => {
const context = await suite.newBrowserContext({ viewport: { height: 900, width: 1280 } });
const page = await context.newPage();
const sessionKey = "agent:main:adopted-marquee";
const catalogResponse = (name: string) => ({
catalogs: [
{
id: "codex",
label: "Codex",
capabilities: { continueSession: true, archive: true },
hosts: [
{
hostId: "gateway:local",
label: "Local Codex",
kind: "gateway",
connected: true,
sessions: [
{
threadId: "thread-adopted-marquee",
sessionKey,
name,
status: "idle",
archived: false,
canContinue: true,
canArchive: true,
},
],
},
],
},
],
});
const initialName = "Trace every adopted catalog refresh before releasing the sidebar";
const gateway = await installMockGateway(page, {
featureMethods: ["chat.metadata", "chat.startup", "sessions.catalog.list"],
methodResponses: {
"sessions.list": chatSessionListResponse([
{ key: sessionKey, kind: "direct", label: initialName, updatedAt: 1 },
]),
"sessions.catalog.list": catalogResponse(initialName),
},
});
try {
await page.goto(`${suite.server.baseUrl}chat`);
const catalog = page.locator('[data-session-section="catalog:codex"]');
await catalog.waitFor({ state: "visible" });
const toggle = catalog.locator(".sidebar-session-group-toggle");
if ((await toggle.getAttribute("aria-expanded")) === "false") {
await toggle.click();
}
const row = catalog.locator(`[data-session-key="${sessionKey}"]`);
await row.hover();
const menu = row.locator("[data-session-menu]");
await expect
.poll(() => menu.evaluate((element) => getComputedStyle(element).opacity))
.toBe("1");
await menu.hover();
const label = row.locator(".hover-marquee");
await expect
.poll(() => label.evaluate((element) => element.classList.value), { timeout: 1_500 })
.toContain("hover-marquee--scrolling");
const requestCount = (await gateway.getRequests("sessions.catalog.list")).length;
await gateway.setMethodResponse("sessions.catalog.list", catalogResponse("Short"));
await page.evaluate(() => window.dispatchEvent(new Event("focus")));
await expect
.poll(async () => (await gateway.getRequests("sessions.catalog.list")).length)
.toBeGreaterThan(requestCount);
await expect.poll(() => label.textContent()).toBe("Short");
expect(await row.evaluate((element) => element.matches(":hover"))).toBe(true);
await expect
.poll(() => label.evaluate((element) => element.classList.value))
.not.toContain("hover-marquee--scrolling");
await expect
.poll(() =>
label.evaluate((element) => element.style.getPropertyValue("--hover-marquee-shift")),
)
.toBe("");
} finally {
await suite.closeBrowserContext(context);
}
});
it("remeasures an adopted marquee when live endcap state changes", async () => {
const context = await suite.newBrowserContext({ viewport: { height: 900, width: 1280 } });
const page = await context.newPage();
const sessionKey = "agent:main:adopted-live-marquee";
const label = "Trace every adopted session transition before releasing the sidebar";
const catalogResponse = {
catalogs: [
{
id: "codex",
label: "Codex",
capabilities: { continueSession: true, archive: true },
hosts: [
{
hostId: "gateway:local",
label: "Local Codex",
kind: "gateway",
connected: true,
sessions: [
{
threadId: "thread-adopted-live-marquee",
sessionKey,
name: label,
status: "idle",
archived: false,
canContinue: true,
canArchive: true,
},
],
},
],
},
],
};
const sessionResponse = (state: Record<string, unknown> = {}) =>
chatSessionListResponse([
{
key: sessionKey,
kind: "direct",
label,
updatedAt: 1,
...state,
},
]);
const gateway = await installMockGateway(page, {
featureMethods: ["chat.metadata", "chat.startup", "sessions.catalog.list"],
methodResponses: {
"sessions.list": sessionResponse(),
"sessions.catalog.list": catalogResponse,
},
});
try {
await page.goto(`${suite.server.baseUrl}chat`);
const catalog = page.locator('[data-session-section="catalog:codex"]');
await catalog.waitFor({ state: "visible" });
const toggle = catalog.locator(".sidebar-session-group-toggle");
if ((await toggle.getAttribute("aria-expanded")) === "false") {
await toggle.click();
}
const row = catalog.locator(`[data-session-key="${sessionKey}"]`);
await row.hover();
const menu = row.locator("[data-session-menu]");
await expect
.poll(() => menu.evaluate((element) => getComputedStyle(element).opacity))
.toBe("1");
await menu.hover();
const marquee = row.locator(".hover-marquee");
await expect
.poll(() => marquee.evaluate((element) => element.classList.value), { timeout: 1_500 })
.toContain("hover-marquee--scrolling");
const idleShift = await marquee.evaluate((element) =>
Number.parseFloat(getComputedStyle(element).getPropertyValue("--hover-marquee-shift")),
);
const refreshSessions = async (state: Record<string, unknown>) => {
const requestCount = (await gateway.getRequests("sessions.list")).length;
await gateway.setMethodResponse("sessions.list", sessionResponse(state));
await gateway.emitGatewayEvent("sessions.changed", {
reason: "update",
sessionKey,
});
await expect
.poll(async () => (await gateway.getRequests("sessions.list")).length)
.toBeGreaterThan(requestCount);
};
await refreshSessions({
activeRunIds: ["run-adopted-live-marquee"],
hasActiveRun: true,
status: "running",
updatedAt: 2,
});
await row.locator(".session-run-spinner").waitFor();
expect(await row.evaluate((element) => element.matches(":hover"))).toBe(true);
await expect
.poll(() =>
marquee.evaluate((element) =>
Number.parseFloat(getComputedStyle(element).getPropertyValue("--hover-marquee-shift")),
),
)
.toBeLessThan(idleShift);
} finally {
await suite.closeBrowserContext(context);
}
});
it("restarts a catalog marquee when its hovered label changes", async () => {
const context = await suite.newBrowserContext({ viewport: { height: 900, width: 1280 } });
const page = await context.newPage();
const catalogResponse = (name: string, pullRequest?: { numbers: number[]; state: "open" }) => ({
catalogs: [
{
id: "codex",
label: "Codex",
capabilities: { continueSession: true, archive: true },
hosts: [
{
hostId: "gateway:local",
label: "Local Codex",
kind: "gateway",
connected: true,
sessions: [
{
threadId: "thread-hovered",
name,
status: "idle",
archived: false,
canContinue: true,
canArchive: true,
...(pullRequest ? { pullRequest } : {}),
},
],
},
],
},
],
});
const initialName = "Trace the complete native catalog refresh lifecycle before release";
const updatedName = "Verify the rewritten catalog title keeps scrolling under the pointer";
const gateway = await installMockGateway(page, {
featureMethods: ["chat.metadata", "chat.startup", "sessions.catalog.list"],
methodResponses: {
"sessions.list": chatSessionListResponse(),
"sessions.catalog.list": catalogResponse(initialName),
},
});
try {
await page.goto(`${suite.server.baseUrl}chat`);
const catalog = page.locator('[data-session-section="catalog:codex"]');
await catalog.waitFor({ state: "visible" });
const toggle = catalog.locator(".sidebar-session-group-toggle");
if ((await toggle.getAttribute("aria-expanded")) === "false") {
await toggle.click();
}
const row = catalog.locator('[data-session-key$=":thread-hovered"]');
await row.hover();
const menu = row.locator("[data-catalog-session-menu]");
await expect
.poll(() => menu.evaluate((element) => getComputedStyle(element).opacity))
.toBe("1");
await menu.hover();
const initialLabel = row.locator(".hover-marquee");
await expect
.poll(() => initialLabel.evaluate((element) => element.classList.value), { timeout: 1_500 })
.toContain("hover-marquee--scrolling");
const requestCount = (await gateway.getRequests("sessions.catalog.list")).length;
await gateway.setMethodResponse("sessions.catalog.list", catalogResponse(updatedName));
await page.evaluate(() => window.dispatchEvent(new Event("focus")));
await expect
.poll(async () => (await gateway.getRequests("sessions.catalog.list")).length)
.toBeGreaterThan(requestCount);
const label = row.locator(".hover-marquee");
await expect.poll(() => label.textContent()).toBe(updatedName);
expect(await row.evaluate((element) => element.matches(":hover"))).toBe(true);
await expect
.poll(() => label.evaluate((element) => element.classList.value), { timeout: 1_500 })
.toContain("hover-marquee--scrolling");
const shiftWithoutBadge = await label.evaluate((element) =>
Number.parseFloat(getComputedStyle(element).getPropertyValue("--hover-marquee-shift")),
);
const badgeRequestCount = (await gateway.getRequests("sessions.catalog.list")).length;
await gateway.setMethodResponse(
"sessions.catalog.list",
catalogResponse(updatedName, { numbers: [125820], state: "open" }),
);
await page.evaluate(() => window.dispatchEvent(new Event("focus")));
await expect
.poll(async () => (await gateway.getRequests("sessions.catalog.list")).length)
.toBeGreaterThan(badgeRequestCount);
await row
.locator('.session-row-badge--pull-request[data-pull-request-state="open"]')
.waitFor();
await expect
.poll(() => label.evaluate((element) => element.classList.value), { timeout: 1_500 })
.toContain("hover-marquee--scrolling");
await expect
.poll(() =>
label.evaluate((element) =>
Number.parseFloat(getComputedStyle(element).getPropertyValue("--hover-marquee-shift")),
),
)
.toBeLessThan(shiftWithoutBadge);
} finally {
await suite.closeBrowserContext(context);
}
});
});
+8 -31
View File
@@ -5,7 +5,6 @@ import { expect, it } from "vitest";
import type { SessionsCatalogHostEvent } from "../../../packages/gateway-protocol/src/index.ts";
import { controlUiSessionPath, installMockGateway } from "../test-helpers/control-ui-e2e.ts";
import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts";
import { expectHoverMarqueeAfterActionsAppear } from "./session-management.test-support.ts";
const suite = createControlUiE2eSuite({
name: "Codex native session catalog",
@@ -358,7 +357,7 @@ suite.define(() => {
sessions: [
{
threadId: "thread-local",
name: "Title fits until menu appears",
name: "Local planning session",
cwd: "/Users/dev/openclaw",
status: "idle",
archived: false,
@@ -452,7 +451,7 @@ suite.define(() => {
.evaluateAll((items) => items.map((item) => item.getAttribute("role"))),
).toEqual(["listitem", "listitem"]);
const openclawProject = section.locator(
'[data-session-catalog-project="/Users/dev/openclaw"]',
'[data-session-catalog-project="project:/Users/dev/openclaw"]',
);
const openclawProjectItem = openclawProject.locator("..");
const openclawProjectList = openclawProjectItem.locator(":scope > [role=list]");
@@ -465,7 +464,6 @@ suite.define(() => {
await openclawProject.locator(".sidebar-session-catalog-project__count").textContent(),
).toBe("2");
const projectRows = section.locator(".sidebar-recent-session--catalog-project-child");
const localCatalogRow = section.locator('[data-session-key$=":thread-local"]');
await expect.poll(() => projectRows.count()).toBe(3);
expect(
await openclawProjectList
@@ -506,13 +504,8 @@ suite.define(() => {
// instead of reserving a phantom second line.
for (const metric of threadRowMetrics) {
expect(metric.singleLine).toBe(true);
expect(metric.height).toBeCloseTo(30, 1);
}
const collapsedHeight = threadRowMetrics.at(0)?.height ?? Number.NaN;
for (const metric of threadRowMetrics) {
expect(metric.height).toBeCloseTo(collapsedHeight, 3);
}
// Collapsed rows sit on the 30px min-height floor; renderer sub-pixels vary.
expect(collapsedHeight).toBeCloseTo(30, 1);
for (const metric of threadRowMetrics) {
expect(metric).toMatchObject({
minHeight: "30px",
@@ -521,22 +514,6 @@ suite.define(() => {
paddingTop: "4px",
});
}
const catalogRow = projectRows.first();
await expectHoverMarqueeAfterActionsAppear(catalogRow);
const catalogActionReservation = await catalogRow.evaluate((row) => {
const text = row.querySelector<HTMLElement>(".sidebar-recent-session__text");
const menu = row.querySelector<HTMLElement>("[data-catalog-session-menu]");
return {
actionCount: row.getAttribute("data-session-row-action-count"),
menuWidth: menu?.getBoundingClientRect().width ?? 0,
paddingRight: text ? Number.parseFloat(getComputedStyle(text).paddingRight) : 0,
};
});
expect(catalogActionReservation.actionCount).toBe("1");
expect(catalogActionReservation.paddingRight).toBeCloseTo(
catalogActionReservation.menuWidth + 3,
0,
);
const projectLabelTone = await openclawProject
.locator(".sidebar-session-catalog-project__label")
.evaluate((label) => {
@@ -568,7 +545,7 @@ suite.define(() => {
expect(projectLabelTone.distanceToText).toBeLessThan(projectLabelTone.distanceToMuted);
expect(
await section
.locator('[data-session-catalog-project="/Users/dev/other"]')
.locator('[data-session-catalog-project="project:/Users/dev/other"]')
.locator(".sidebar-session-catalog-project__label")
.textContent(),
).toBe("other");
@@ -646,7 +623,7 @@ suite.define(() => {
await openclawProject.click();
await expect.poll(() => openclawProject.getAttribute("aria-expanded")).toBe("false");
expect(await localCatalogRow.count()).toBe(0);
expect(await section.getByText("Local planning session", { exact: true }).count()).toBe(0);
expect(await section.getByText("Worktree fix session", { exact: true }).count()).toBe(0);
expect(await section.getByText("Other project session", { exact: true }).count()).toBe(1);
expect(await openclawProject.count()).toBe(1);
@@ -658,18 +635,18 @@ suite.define(() => {
(key) => JSON.parse(localStorage.getItem(key) ?? "[]"),
collapsedSessionSectionsStorageKey,
),
).toContain("catalog-project:codex:gateway:local:/Users/dev/openclaw");
).toContain("catalog-project:codex:gateway:local:project:/Users/dev/openclaw");
await openclawProject.click();
await expect.poll(() => openclawProject.getAttribute("aria-expanded")).toBe("true");
expect(await localCatalogRow.count()).toBe(1);
expect(await section.getByText("Local planning session", { exact: true }).count()).toBe(1);
expect(await section.getByText("Worktree fix session", { exact: true }).count()).toBe(1);
expect(
await page.evaluate(
(key) => JSON.parse(localStorage.getItem(key) ?? "[]"),
collapsedSessionSectionsStorageKey,
),
).not.toContain("catalog-project:codex:gateway:local:/Users/dev/openclaw");
).not.toContain("catalog-project:codex:gateway:local:project:/Users/dev/openclaw");
if (captureUiProofEnabled) {
await mkdir(uiProofArtifactDir, { recursive: true });
@@ -1,176 +0,0 @@
import { expect, it } from "vitest";
import { CATALOG_SESSION_CONTINUED_EVENT } from "../lib/sessions/catalog-key.ts";
import {
chatSessionListResponse,
createChatFlowE2eSuite,
installMockGateway,
} from "./chat-flow.test-support.ts";
const suite = createChatFlowE2eSuite();
function catalogResponse(
sessions: Array<{
threadId: string;
name: string;
cwd: string;
sessionKey?: string;
}>,
) {
return {
catalogs: [
{
id: "codex",
label: "Codex",
capabilities: { continueSession: true, archive: true },
hosts: [
{
hostId: "gateway:local",
label: "Local Codex",
kind: "gateway",
connected: true,
sessions: sessions.map((session) => ({
...session,
status: "idle",
archived: false,
canContinue: true,
canArchive: true,
})),
},
],
},
],
};
}
suite.define(() => {
it("preserves a focused catalog row when project groups reorder", async () => {
const context = await suite.newBrowserContext({});
const page = await context.newPage();
const first = { threadId: "thread-project-a", name: "Project A", cwd: "/work/project-a" };
const second = { threadId: "thread-project-b", name: "Project B", cwd: "/work/project-b" };
const gateway = await installMockGateway(page, {
featureMethods: ["chat.metadata", "chat.startup", "sessions.catalog.list"],
methodResponses: {
"sessions.list": chatSessionListResponse(),
"sessions.catalog.list": catalogResponse([first, second]),
},
});
try {
await page.goto(`${suite.server.baseUrl}chat`);
const catalog = page.locator('[data-session-section="catalog:codex"]');
await catalog.waitFor({ state: "visible" });
const toggle = catalog.locator(".sidebar-session-group-toggle");
if ((await toggle.getAttribute("aria-expanded")) === "false") {
await toggle.click();
}
const row = catalog.locator('[data-session-key$=":thread-project-a"]');
const menu = row.locator("[data-catalog-session-menu]");
await menu.focus();
await row.evaluate((element) => element.setAttribute("data-identity-probe", "kept"));
const requestCount = (await gateway.getRequests("sessions.catalog.list")).length;
await gateway.setMethodResponse("sessions.catalog.list", catalogResponse([second, first]));
await page.evaluate(() => window.dispatchEvent(new Event("focus")));
await expect
.poll(async () => (await gateway.getRequests("sessions.catalog.list")).length)
.toBeGreaterThan(requestCount);
await expect
.poll(() =>
catalog
.locator("[data-session-catalog-project]")
.evaluateAll((elements) =>
elements.map((element) => element.getAttribute("data-session-catalog-project")),
),
)
.toEqual(["/work/project-b", "/work/project-a"]);
expect(await row.getAttribute("data-identity-probe")).toBe("kept");
await expect
.poll(() => menu.evaluate((element) => element === document.activeElement))
.toBe(true);
} finally {
await suite.closeBrowserContext(context);
}
});
it.each(["Escape", "Tab"])(
"returns focus to the adopted row when its open catalog menu closes with %s",
async (dismissKey) => {
const context = await suite.newBrowserContext({});
const page = await context.newPage();
const sessionKey = "agent:main:adopted-open-menu";
const catalogSession = {
threadId: "thread-adopted-open-menu",
name: "Adopt while its menu is open",
cwd: "/work/openclaw",
};
await installMockGateway(page, {
featureMethods: ["chat.metadata", "chat.startup", "sessions.catalog.list"],
methodResponses: {
"sessions.list": chatSessionListResponse([
{ key: sessionKey, kind: "direct", label: catalogSession.name, updatedAt: 1 },
]),
"sessions.catalog.list": catalogResponse([catalogSession]),
},
});
try {
await page.goto(`${suite.server.baseUrl}chat`);
const catalog = page.locator('[data-session-section="catalog:codex"]');
await catalog.waitFor({ state: "visible" });
const toggle = catalog.locator(".sidebar-session-group-toggle");
if ((await toggle.getAttribute("aria-expanded")) === "false") {
await toggle.click();
}
const catalogRow = catalog.locator('[data-session-key$=":thread-adopted-open-menu"]');
await catalogRow.hover();
await catalogRow.locator("[data-catalog-session-menu]").click();
const popup = page.locator("openclaw-catalog-session-menu");
await popup.waitFor({ state: "visible" });
await expect
.poll(() => page.evaluate(() => document.activeElement?.localName))
.toBe("wa-dropdown-item");
await page.evaluate(
({ eventName, adoptedSessionKey }) => {
document.dispatchEvent(
new CustomEvent(eventName, {
detail: {
agentId: "main",
catalogId: "codex",
hostId: "gateway:local",
sessionKey: adoptedSessionKey,
threadId: "thread-adopted-open-menu",
},
}),
);
},
{ eventName: CATALOG_SESSION_CONTINUED_EVENT, adoptedSessionKey: sessionKey },
);
const adoptedMenu = catalog.locator(
`[data-session-key="${sessionKey}"] [data-session-menu]`,
);
await adoptedMenu.waitFor({ state: "attached" });
await expect.poll(() => adoptedMenu.getAttribute("aria-expanded")).toBe("true");
await popup.getByRole("menuitem").first().press(dismissKey);
await popup.waitFor({ state: "detached" });
if (dismissKey === "Escape") {
await expect
.poll(() => adoptedMenu.evaluate((element) => element === document.activeElement))
.toBe(true);
} else {
await expect
.poll(() =>
page.evaluate(() => {
const active = document.activeElement;
return Boolean(active?.isConnected && active !== document.body);
}),
)
.toBe(true);
}
} finally {
await suite.closeBrowserContext(context);
}
},
);
});
@@ -3,7 +3,6 @@ import path from "node:path";
import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures";
import type { Locator, Page } from "playwright";
import { expect } from "vitest";
import type { GatewaySessionRow } from "../api/types.ts";
import {
controlUiSessionPath,
controlUiSessionUrl,
@@ -46,8 +45,6 @@ export function sessionRow(
pinned?: boolean;
pinnedAt?: number;
hasActiveRun?: boolean;
hasAutomation?: GatewaySessionRow["hasAutomation"];
incognito?: GatewaySessionRow["incognito"];
unread?: boolean;
status?: string;
spawnedBy?: string;
@@ -167,37 +164,6 @@ export function actionPointerEvents(button: Locator): Promise<string> {
return button.evaluate((element) => globalThis.getComputedStyle(element).pointerEvents);
}
function measureMarqueeLabel(
label: Locator,
): Promise<{ scrollWidth: number; viewportWidth: number }> {
return label.evaluate((element) => {
const viewport = element.parentElement;
if (!(viewport instanceof HTMLElement)) {
throw new Error("Marquee label must have an HTMLElement viewport");
}
const style = getComputedStyle(viewport);
return {
scrollWidth: element.scrollWidth,
viewportWidth:
viewport.clientWidth -
(Number.parseFloat(style.paddingLeft) || 0) -
(Number.parseFloat(style.paddingRight) || 0),
};
});
}
export async function expectHoverMarqueeAfterActionsAppear(row: Locator): Promise<void> {
const label = row.locator(".sidebar-recent-session__name");
const resting = await measureMarqueeLabel(label);
expect(resting.scrollWidth, JSON.stringify(resting)).toBeLessThanOrEqual(resting.viewportWidth);
await row.hover();
const hovered = await measureMarqueeLabel(label);
expect(hovered.scrollWidth, JSON.stringify(hovered)).toBeGreaterThan(hovered.viewportWidth);
await expect
.poll(() => label.evaluate((element) => element.classList.value), { timeout: 1_500 })
.toContain("hover-marquee--scrolling");
}
/**
* Opens a session-menu submenu through the keyboard path. Submenu ARIA is ready
* before Web Awesome finishes opening the dropdown, so hovering alone races the
@@ -16,6 +16,7 @@ const suite = createSessionManagementE2eSuite();
suite.define(() => {
it("vertically centers session actions in a two-line row", async () => {
const context = await suite.browser.newContext({
hasTouch: true,
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
@@ -40,100 +41,44 @@ suite.define(() => {
await page.goto(`${suite.server.baseUrl}chat`);
const row = page.locator('[data-session-key="agent:main:two-line"]');
await row.waitFor({ state: "visible", timeout: 10_000 });
await row.hover();
const pin = row.getByRole("button", { name: "Unpin session" });
const menu = row.getByRole("button", { name: "Open session menu" });
await expect.poll(() => actionOpacity(pin)).toBe("1");
await pin.hover();
await captureUiProof(page, "sidebar-session-actions-centered.png");
const [rowBounds, titleBounds, subtitleBounds, pinBounds, pinGlyphBounds, menuBounds] =
await Promise.all([
row.boundingBox(),
row.locator(".sidebar-recent-session__name").boundingBox(),
row.locator(".sidebar-recent-session__subtitle").boundingBox(),
pin.boundingBox(),
pin.locator("svg").boundingBox(),
menu.boundingBox(),
]);
if (
!rowBounds ||
!titleBounds ||
!subtitleBounds ||
!pinBounds ||
!pinGlyphBounds ||
!menuBounds
) {
const [rowBounds, subtitleBounds, pinBounds, menuBounds] = await Promise.all([
row.boundingBox(),
row.locator(".sidebar-recent-session__subtitle").boundingBox(),
pin.boundingBox(),
menu.boundingBox(),
]);
if (!rowBounds || !subtitleBounds || !pinBounds || !menuBounds) {
throw new Error("Expected visible two-line session action geometry");
}
const rowCenter = rowBounds.y + rowBounds.height / 2;
const titleCenter = titleBounds.y + titleBounds.height / 2;
// Two-line rows anchor the actions to the title line so the subtitle keeps
// its own line; the row centre falls between the two lines instead.
expect(subtitleBounds.y + subtitleBounds.height / 2).toBeGreaterThan(rowCenter);
expect(Math.abs(pinBounds.y + pinBounds.height / 2 - titleCenter)).toBeLessThanOrEqual(1);
expect(Math.abs(menuBounds.y + menuBounds.height / 2 - titleCenter)).toBeLessThanOrEqual(1);
expect(pinBounds.width).toBeGreaterThanOrEqual(24);
expect(pinBounds.height).toBeGreaterThanOrEqual(24);
expect(menuBounds.width).toBeGreaterThanOrEqual(24);
expect(menuBounds.height).toBeGreaterThanOrEqual(24);
expect(pinGlyphBounds.y + pinGlyphBounds.height).toBeLessThanOrEqual(subtitleBounds.y);
const subtitleHitTarget = await page.evaluate(
({ x, y }) => {
const target = document.elementFromPoint(x, y);
return {
action: target?.closest(".session-action")?.getAttribute("aria-label") ?? null,
link: target?.closest(".sidebar-recent-session__link") !== null,
};
},
{
x: pinBounds.x + pinBounds.width / 2,
y: subtitleBounds.y + 1,
},
);
expect(subtitleHitTarget).toEqual({ action: null, link: true });
expect(subtitleBounds.y).toBeGreaterThan(rowCenter);
expect(Math.abs(pinBounds.y + pinBounds.height / 2 - rowCenter)).toBeLessThanOrEqual(1);
expect(Math.abs(menuBounds.y + menuBounds.height / 2 - rowCenter)).toBeLessThanOrEqual(1);
await page.evaluate(() => {
document.documentElement.style.setProperty("--control-ui-text-scale", "1.4");
});
const [
scaledTitleBounds,
scaledSubtitleBounds,
scaledPinBounds,
scaledPinGlyphBounds,
scaledMenuBounds,
] = await Promise.all([
row.locator(".sidebar-recent-session__name").boundingBox(),
row.locator(".sidebar-recent-session__subtitle").boundingBox(),
pin.boundingBox(),
pin.locator("svg").boundingBox(),
menu.boundingBox(),
]);
if (
!scaledTitleBounds ||
!scaledSubtitleBounds ||
!scaledPinBounds ||
!scaledPinGlyphBounds ||
!scaledMenuBounds
) {
throw new Error("Expected scaled two-line session action geometry");
}
const scaledTitleCenter = scaledTitleBounds.y + scaledTitleBounds.height / 2;
expect(
Math.abs(scaledPinBounds.y + scaledPinBounds.height / 2 - scaledTitleCenter),
).toBeLessThanOrEqual(1);
expect(
Math.abs(scaledMenuBounds.y + scaledMenuBounds.height / 2 - scaledTitleCenter),
).toBeLessThanOrEqual(1);
expect(scaledPinGlyphBounds.y + scaledPinGlyphBounds.height).toBeLessThanOrEqual(
scaledSubtitleBounds.y,
);
await expect
.poll(async () => {
const [title, details] = await Promise.all([
row.locator(".sidebar-recent-session__title-row").boundingBox(),
row.locator(".sidebar-recent-session__details").boundingBox(),
]);
return title && details ? title.y + title.height - details.y : Number.POSITIVE_INFINITY;
})
.toBeLessThanOrEqual(0.5);
await captureUiProof(page, "sidebar-session-text-scale-140.png");
} finally {
await context.close();
}
});
it("keeps action-only text widest at rest and keeps active state lit", async () => {
it("keeps action-only text stable and active state visible with actions", async () => {
const context = await suite.browser.newContext({
locale: "en-US",
serviceWorkers: "block",
@@ -175,41 +120,23 @@ suite.define(() => {
await actionOnlyRow.hover();
await expect.poll(() => actionOpacity(actionOnlyPin)).toBe("1");
const [actionOnlyHoveredTextBounds, actionOnlyHoveredNameBounds, actionOnlyHoveredPinBounds] =
await Promise.all([
actionOnlyText.boundingBox(),
actionOnlyRow.locator(".sidebar-recent-session__name").boundingBox(),
actionOnlyPin.boundingBox(),
]);
await expect
.poll(() => actionOnlyText.evaluate((element) => getComputedStyle(element).paddingRight))
.toBe("52px");
const hoveredTextBounds = await actionOnlyText.boundingBox();
await page.mouse.move(0, 0);
await actionOnlyPin.focus();
await expect.poll(() => actionOpacity(actionOnlyPin)).toBe("1");
const [actionOnlyFocusedTextBounds, actionOnlyFocusedNameBounds, actionOnlyFocusedPinBounds] =
await Promise.all([
actionOnlyText.boundingBox(),
actionOnlyRow.locator(".sidebar-recent-session__name").boundingBox(),
actionOnlyPin.boundingBox(),
]);
if (
!restingTextBounds ||
!actionOnlyHoveredTextBounds ||
!actionOnlyHoveredNameBounds ||
!actionOnlyHoveredPinBounds ||
!actionOnlyFocusedTextBounds ||
!actionOnlyFocusedNameBounds ||
!actionOnlyFocusedPinBounds
) {
await expect
.poll(() => actionOnlyText.evaluate((element) => getComputedStyle(element).paddingRight))
.toBe("52px");
const focusedTextBounds = await actionOnlyText.boundingBox();
if (!restingTextBounds || !hoveredTextBounds || !focusedTextBounds) {
throw new Error("Expected visible action-only text geometry");
}
expect(actionOnlyHoveredTextBounds.width).toBeCloseTo(restingTextBounds.width, 1);
expect(actionOnlyFocusedTextBounds.width).toBeCloseTo(restingTextBounds.width, 1);
expect(actionOnlyHoveredNameBounds.x + actionOnlyHoveredNameBounds.width).toBeLessThanOrEqual(
actionOnlyHoveredPinBounds.x + 1,
);
expect(actionOnlyFocusedNameBounds.x + actionOnlyFocusedNameBounds.width).toBeLessThanOrEqual(
actionOnlyFocusedPinBounds.x + 1,
);
expect(hoveredTextBounds.width).toBeCloseTo(restingTextBounds.width, 1);
expect(focusedTextBounds.width).toBeCloseTo(restingTextBounds.width, 1);
const row = page.locator('[data-session-key="agent:main:hover-active"]');
await row.waitFor({ state: "visible", timeout: 10_000 });
@@ -220,20 +147,16 @@ suite.define(() => {
await expect.poll(() => actionOpacity(state)).toBe("1");
await row.hover();
// The spinner is the signal that the row is still working; hovering to
// reach the actions must not take it away on a two-line row.
await expect.poll(() => actionOpacity(state)).toBe("1");
await expect.poll(() => state.locator(".session-run-spinner").isVisible()).toBe(true);
await expect.poll(() => actionOpacity(pin)).toBe("1");
await expect.poll(() => actionOpacity(menu)).toBe("1");
const [nameBounds, pinBounds, menuBounds, stateBounds] = await Promise.all([
const [nameBounds, pinBounds, menuBounds] = await Promise.all([
row.locator(".sidebar-recent-session__name").boundingBox(),
pin.boundingBox(),
menu.boundingBox(),
state.boundingBox(),
]);
if (!nameBounds || !pinBounds || !menuBounds || !stateBounds) {
if (!nameBounds || !pinBounds || !menuBounds) {
throw new Error("Expected visible hovered action geometry");
}
expect(nameBounds.y + nameBounds.height / 2).toBeCloseTo(
@@ -241,9 +164,6 @@ suite.define(() => {
1,
);
expect(pinBounds.x + pinBounds.width).toBeLessThanOrEqual(menuBounds.x);
// The reservation slides the endcap clear instead of hiding it, so the
// run spinner and unread dot survive the hover that reveals the buttons.
expect(stateBounds.x + stateBounds.width).toBeLessThanOrEqual(pinBounds.x);
await page.mouse.move(0, 0);
await pin.focus();
@@ -318,10 +238,10 @@ suite.define(() => {
expect(
Math.abs(forkBounds.y + forkBounds.height / 2 - (nameBounds.y + nameBounds.height / 2)),
).toBeLessThanOrEqual(2);
// The actions ride the title's midline now, and the title ends before them.
expect(
Math.abs(nameBounds.y + nameBounds.height / 2 - (pinBounds.y + pinBounds.height / 2)),
).toBeLessThanOrEqual(1);
expect(nameBounds.y + nameBounds.height / 2).toBeCloseTo(
pinBounds.y + pinBounds.height / 2,
1,
);
expect(nameBounds.x + nameBounds.width).toBeLessThanOrEqual(pinBounds.x + 1);
expect(pinBounds.x + pinBounds.width).toBeLessThanOrEqual(menuBounds.x);
} finally {
@@ -329,82 +249,6 @@ suite.define(() => {
}
});
it("lines the whole trailing column up on one pitch", async () => {
const context = await suite.browser.newContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
await installMockGateway(page, {
methodResponses: {
"sessions.list": sessionsListResponse([
sessionRow("agent:main:main", "Main", Date.now()),
// A preview gives the row its second line, which is where the endcap
// sits directly under the action icons.
Object.assign(
sessionRow("agent:main:badged", "Badged session", Date.now() - 1, {
hasAutomation: true,
incognito: true,
status: "done",
unread: true,
}),
{ lastMessagePreview: "Kept the endcap lit under the hover actions" },
),
]),
},
sessionKey: "agent:main:main",
});
try {
await page.goto(`${suite.server.baseUrl}chat`);
const row = page.locator('[data-session-key="agent:main:badged"]');
await row.waitFor({ state: "visible", timeout: 10_000 });
await expect.poll(() => row.locator(".session-unread-dot").isVisible()).toBe(true);
await row.hover();
await expect
.poll(() =>
row.locator("[data-session-menu]").evaluate((el) => getComputedStyle(el).opacity),
)
.toBe("1");
const column = await row.evaluate((element) => {
const centres = (root: Element | null, selector: string) =>
[...(root?.querySelectorAll(selector) ?? [])]
.map((glyph) => glyph.getBoundingClientRect())
.filter((rect) => rect.width > 0)
.map((rect) => Math.round((rect.left + rect.width / 2) * 10) / 10)
.toSorted((left, right) => left - right);
return {
actions: centres(element, ".session-action svg"),
endcap: centres(
element.querySelector(".sidebar-recent-session__details-endcap"),
"svg, .session-unread-dot, .session-run-spinner",
),
};
});
// The endcap's bare glyphs and the action icons above them read as one
// column, so they need one pitch and one right-hand axis. Sized to their
// own boxes the buttons stepped 25px while the badges stepped 20px and
// ended 5px further right, so nothing sat under anything.
expect(column.endcap.length).toBeGreaterThan(1);
expect(column.actions.length).toBeGreaterThan(1);
const stepsOf = (centres: number[]) =>
centres.slice(1).map((centre, index) => Math.round(centre - (centres[index] as number)));
const steps = [...stepsOf(column.endcap), ...stepsOf(column.actions)];
for (const step of steps) {
expect(step, JSON.stringify(column)).toBe(steps[0]);
}
expect(column.endcap.at(-1), JSON.stringify(column)).toBeCloseTo(
column.actions.at(-1) as number,
0,
);
} finally {
await context.close();
}
});
it("keeps the trailing unread dot on one axis with and without a pull-request icon", async () => {
const plainKey = "agent:main:unread-plain";
const pullRequestKey = "agent:main:unread-pr";
@@ -500,89 +344,6 @@ suite.define(() => {
}
});
it("draws every row glyph at one size", async () => {
const context = await suite.browser.newContext({
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
await installMockGateway(page, {
methodResponses: {
"sessions.list": sessionsListResponse([
sessionRow("agent:main:main", "Main", Date.now()),
sessionRow("agent:main:mixed", "Mixed glyphs", Date.now() - 1, {
forkSource: { sessionKey: "agent:main:main", sessionId: "source-session" },
hasActiveRun: true,
hasAutomation: true,
incognito: true,
status: "running",
unread: true,
}),
sessionRow("agent:main:archived", "Archived glyph", Date.now() - 2, {
archived: true,
}),
sessionRow("agent:main:queued", "Queued glyph", Date.now() - 3, {
hasActiveRun: true,
status: "queued",
}),
]),
},
sessionKey: "agent:main:archived",
});
try {
await page.goto(`${suite.server.baseUrl}chat`);
const row = page.locator('[data-session-key="agent:main:mixed"]');
const archivedRow = page.locator('[data-session-key="agent:main:archived"]');
const queuedRow = page.locator('[data-session-key="agent:main:queued"]');
await row.waitFor({ state: "visible", timeout: 10_000 });
await archivedRow.waitFor({ state: "visible", timeout: 10_000 });
await queuedRow.waitFor({ state: "visible", timeout: 10_000 });
await row.hover();
await expect
.poll(() =>
row.locator("[data-session-menu]").evaluate((el) => getComputedStyle(el).opacity),
)
.toBe("1");
// Badges, fork provenance and the action icons drew themselves at
// different sizes on the same line, which reads as broken alignment. The
// unread dot is a dot rather than a glyph and keeps its own size.
const measured = await page
.locator(
'[data-session-key="agent:main:mixed"], [data-session-key="agent:main:archived"], [data-session-key="agent:main:queued"]',
)
.evaluateAll((elements) => {
const glyphSizes = new Set<string>();
for (const element of elements) {
for (const glyph of element.querySelectorAll("svg")) {
if (glyph.getBoundingClientRect().width === 0) {
continue;
}
const style = getComputedStyle(glyph);
glyphSizes.add(
`${Number.parseFloat(style.width)}x${Number.parseFloat(style.height)}`,
);
}
}
const spinner = elements[0]?.querySelector(".session-run-spinner");
return {
glyphSizes: [...glyphSizes],
spinnerWidth: spinner ? Number.parseFloat(getComputedStyle(spinner).width) : null,
};
});
expect(measured.glyphSizes.length, JSON.stringify(measured.glyphSizes)).toBe(1);
// The ring inks its whole box while the icons only ink 9-10px of theirs,
// so it sits three px down rather than matching box for box.
const glyphWidth = Number.parseFloat(measured.glyphSizes[0] as string);
expect(measured.spinnerWidth).toBe(glyphWidth - 3);
} finally {
await context.close();
}
});
it("keeps semantic state beside always-visible touch actions", async () => {
const context = await suite.browser.newContext({
hasTouch: true,
@@ -595,14 +356,10 @@ suite.define(() => {
methodResponses: {
"sessions.list": sessionsListResponse([
sessionRow("agent:main:main", "Main", Date.now()),
Object.assign(
sessionRow("agent:main:touch-active", "Touch active", Date.now() - 1, {
hasActiveRun: true,
status: "running",
}),
{ lastMessagePreview: "Persistent touch subtitle" },
),
sessionRow("agent:main:touch-idle", "Touch idle", Date.now() - 2),
sessionRow("agent:main:touch-active", "Touch active", Date.now() - 1, {
hasActiveRun: true,
status: "running",
}),
]),
},
sessionKey: "agent:main:main",
@@ -611,9 +368,7 @@ suite.define(() => {
try {
await page.goto(`${suite.server.baseUrl}chat`);
const row = page.locator('[data-session-key="agent:main:touch-active"]');
const singleLineRow = page.locator('[data-session-key="agent:main:touch-idle"]');
await row.waitFor({ state: "visible", timeout: 10_000 });
await singleLineRow.waitFor({ state: "visible", timeout: 10_000 });
const state = row.locator(".session-row-state");
const pin = row.getByRole("button", { name: "Pin session" });
const menu = row.getByRole("button", { name: "Open session menu" });
@@ -622,112 +377,17 @@ suite.define(() => {
await expect.poll(() => pin.isVisible()).toBe(true);
await expect.poll(() => menu.isVisible()).toBe(true);
const [rowBounds, stateBounds, pinBounds, menuBounds] = await Promise.all([
row.boundingBox(),
state.boundingBox(),
pin.boundingBox(),
menu.boundingBox(),
]);
if (!rowBounds || !stateBounds || !pinBounds || !menuBounds) {
const [stateBounds, pinBounds] = await Promise.all([state.boundingBox(), pin.boundingBox()]);
if (!stateBounds || !pinBounds) {
throw new Error("Expected visible touch state and action geometry");
}
expect(rowBounds.height).toBeGreaterThanOrEqual(44);
expect(pinBounds.width).toBeGreaterThanOrEqual(44);
expect(pinBounds.height).toBeGreaterThanOrEqual(44);
expect(menuBounds.width).toBeGreaterThanOrEqual(44);
expect(menuBounds.height).toBeGreaterThanOrEqual(44);
expect(stateBounds.x + stateBounds.width).toBeLessThanOrEqual(pinBounds.x);
const [singleLineRowBounds, singleLineLinkBounds] = await Promise.all([
singleLineRow.boundingBox(),
singleLineRow.locator(".sidebar-recent-session__link").boundingBox(),
]);
if (!singleLineRowBounds || !singleLineLinkBounds) {
throw new Error("Expected visible single-line touch row geometry");
}
expect(singleLineLinkBounds.height).toBeGreaterThanOrEqual(singleLineRowBounds.height);
const topBandIsLink = await page.evaluate(
({ x, y }) =>
document.elementFromPoint(x, y)?.closest(".sidebar-recent-session__link") !== null,
{
x: singleLineRowBounds.x + 20,
y: singleLineRowBounds.y + 1,
},
);
expect(topBandIsLink).toBe(true);
} finally {
await context.close();
}
});
it("contains touch actions within consecutive pinned rows", async () => {
const context = await suite.browser.newContext({
hasTouch: true,
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
const now = Date.now();
await installMockGateway(page, {
methodResponses: {
"sessions.list": sessionsListResponse([
sessionRow("agent:main:main", "Main", now),
sessionRow("agent:main:pinned-first", "Pinned first", now - 1, {
pinned: true,
pinnedAt: now - 1,
}),
sessionRow("agent:main:pinned-second", "Pinned second", now - 2, {
pinned: true,
pinnedAt: now - 2,
}),
]),
},
sessionKey: "agent:main:main",
});
try {
await page.goto(`${suite.server.baseUrl}chat`);
const firstRow = page.locator(
'[data-sidebar-entry="session:agent:main:pinned-first"] .sidebar-recent-session',
);
const secondRow = page.locator(
'[data-sidebar-entry="session:agent:main:pinned-second"] .sidebar-recent-session',
);
await firstRow.waitFor({ state: "visible", timeout: 10_000 });
await secondRow.waitFor({ state: "visible", timeout: 10_000 });
const firstMenu = firstRow.getByRole("button", { name: "Open session menu: Pinned first" });
const [firstRowBounds, secondRowBounds, firstMenuBounds] = await Promise.all([
firstRow.boundingBox(),
secondRow.boundingBox(),
firstMenu.boundingBox(),
]);
if (!firstRowBounds || !secondRowBounds || !firstMenuBounds) {
throw new Error("Expected visible pinned touch row geometry");
}
expect(firstRowBounds.height).toBeGreaterThanOrEqual(44);
expect(secondRowBounds.height).toBeGreaterThanOrEqual(44);
expect(firstMenuBounds.y).toBeGreaterThanOrEqual(firstRowBounds.y);
expect(firstMenuBounds.y + firstMenuBounds.height).toBeLessThanOrEqual(
firstRowBounds.y + firstRowBounds.height,
);
const boundaryTarget = await page.evaluate(
({ x, y }) =>
document
.elementFromPoint(x, y)
?.closest<HTMLElement>(".sidebar-recent-session")
?.getAttribute("data-session-key") ?? null,
{
x: firstMenuBounds.x + firstMenuBounds.width / 2,
y: secondRowBounds.y + 1,
},
);
expect(boundaryTarget).toBe("agent:main:pinned-second");
} finally {
await context.close();
}
});
it("does not widen desktop session text or dim trailing state under hover actions", async () => {
it("does not widen desktop session text when hover actions appear beside trailing state", async () => {
const context = await suite.browser.newContext({
locale: "en-US",
serviceWorkers: "block",
@@ -816,20 +476,6 @@ suite.define(() => {
.toBe(true);
await expect.poll(() => state.locator(".session-run-spinner").isVisible()).toBe(true);
await expect.poll(() => state.locator(".session-unread-dot").count()).toBe(0);
const [pullRequestGlyphStyle, actionGlyphStyle] = await Promise.all([
state.locator("[data-session-pr-state='open'] svg").evaluate((glyph) => {
const style = getComputedStyle(glyph);
return { height: style.height, strokeWidth: style.strokeWidth, width: style.width };
}),
row
.locator(".session-action svg")
.first()
.evaluate((glyph) => {
const style = getComputedStyle(glyph);
return { height: style.height, strokeWidth: style.strokeWidth, width: style.width };
}),
]);
expect(pullRequestGlyphStyle).toEqual(actionGlyphStyle);
const stateLayout = await row.evaluate((element) => {
const endcap = element.querySelector<HTMLElement>(
".sidebar-recent-session__details-endcap",
@@ -868,6 +514,7 @@ suite.define(() => {
expect(atom.right).toBeLessThanOrEqual(stateLayout.endcapRight);
}
const link = row.locator(".sidebar-recent-session__link");
const titleRow = row.locator(".sidebar-recent-session__title-row");
const pin = row.getByRole("button", { name: "Pin session" });
const menu = row.getByRole("button", { name: "Open session menu" });
await expect
@@ -899,29 +546,24 @@ suite.define(() => {
await expect.poll(() => actionOpacity(state)).toBe("1");
await expect.poll(() => actionOpacity(pin)).toBe("1");
await expect.poll(() => actionOpacity(menu)).toBe("1");
await expect
.poll(() => titleRow.evaluate((element) => getComputedStyle(element).paddingRight))
.toBe("52px");
const [textBounds, nameBounds, pinBounds, pinGlyphBounds, menuBounds] = await Promise.all([
const [textBounds, nameBounds, pinBounds, menuBounds] = await Promise.all([
row.locator(".sidebar-recent-session__text").boundingBox(),
row.locator(".sidebar-recent-session__name").boundingBox(),
pin.boundingBox(),
pin.locator("svg").boundingBox(),
menu.boundingBox(),
]);
if (!textBounds || !nameBounds || !pinBounds || !pinGlyphBounds || !menuBounds) {
if (!textBounds || !nameBounds || !pinBounds || !menuBounds) {
throw new Error("Expected visible combined session action geometry");
}
expect(textBounds.width).toBeCloseTo(restingTextBounds.width, 1);
// A control taller than the title line would paint its hover fill over the
// badges that now stay lit directly below it.
const detailsBounds = await row.locator(".sidebar-recent-session__details").boundingBox();
if (!detailsBounds) {
throw new Error("Expected a visible second line");
}
expect(pinGlyphBounds.y + pinGlyphBounds.height).toBeLessThanOrEqual(detailsBounds.y);
// The actions ride the title's midline now, and the title ends before them.
expect(
Math.abs(nameBounds.y + nameBounds.height / 2 - (pinBounds.y + pinBounds.height / 2)),
).toBeLessThanOrEqual(1);
expect(nameBounds.y + nameBounds.height / 2).toBeCloseTo(
pinBounds.y + pinBounds.height / 2,
1,
);
expect(nameBounds.x + nameBounds.width).toBeLessThanOrEqual(pinBounds.x + 1);
expect(pinBounds.x + pinBounds.width).toBeLessThanOrEqual(menuBounds.x);
await page.mouse.move(0, 0);
@@ -929,6 +571,9 @@ suite.define(() => {
await expect.poll(() => actionOpacity(state)).toBe("1");
await expect.poll(() => actionOpacity(pin)).toBe("1");
await expect.poll(() => actionOpacity(menu)).toBe("1");
await expect
.poll(() => titleRow.evaluate((element) => getComputedStyle(element).paddingRight))
.toBe("52px");
const [focusedTextBounds, focusedNameBounds, focusedPinBounds, focusedMenuBounds] =
await Promise.all([
@@ -941,13 +586,10 @@ suite.define(() => {
throw new Error("Expected visible focused session action geometry");
}
expect(focusedTextBounds.width).toBeCloseTo(restingTextBounds.width, 1);
expect(
Math.abs(
focusedNameBounds.y +
focusedNameBounds.height / 2 -
(focusedPinBounds.y + focusedPinBounds.height / 2),
),
).toBeLessThanOrEqual(1);
expect(focusedNameBounds.y + focusedNameBounds.height / 2).toBeCloseTo(
focusedPinBounds.y + focusedPinBounds.height / 2,
1,
);
expect(focusedNameBounds.x + focusedNameBounds.width).toBeLessThanOrEqual(
focusedPinBounds.x + 1,
);
+75 -63
View File
@@ -1,112 +1,69 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { startHoverMarquee, stopHoverMarquee } from "./hover-marquee.ts";
import { startHoverMarqueeFromEvent, stopHoverMarqueeFromEvent } from "./hover-marquee.ts";
let pendingFrame: FrameRequestCallback | undefined;
function enter(row: HTMLElement) {
row.addEventListener("mouseenter", startHoverMarqueeFromEvent, { once: true });
row.dispatchEvent(new MouseEvent("mouseenter"));
}
function runPendingFrame(): void {
const callback = pendingFrame;
pendingFrame = undefined;
callback?.(0);
function leave(row: HTMLElement) {
row.addEventListener("mouseleave", stopHoverMarqueeFromEvent, { once: true });
row.dispatchEvent(new MouseEvent("mouseleave"));
}
function buildRow(params: { textWidth: number; labelWidth: number }) {
const row = document.createElement("div");
const viewport = document.createElement("span");
const label = document.createElement("span");
label.className = "hover-marquee";
label.textContent = "Fix stale iMessage group-allowlist warning copy";
viewport.append(label);
row.append(viewport);
row.append(label);
document.body.append(row);
Object.defineProperty(label, "clientWidth", { value: params.labelWidth });
Object.defineProperty(label, "scrollWidth", { value: params.textWidth });
return { row, viewport, label };
return { row, label };
}
describe("hover marquee", () => {
beforeEach(() => {
vi.useFakeTimers();
pendingFrame = undefined;
vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => {
pendingFrame = callback;
return 1;
});
});
beforeEach(() => vi.useFakeTimers());
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
vi.unstubAllGlobals();
document.body.replaceChildren();
});
it("waits before scrolling overflowing labels by the clipped distance", () => {
const { row, label } = buildRow({ textWidth: 320, labelWidth: 180 });
startHoverMarquee(row);
expect(label.style.getPropertyValue("--hover-marquee-shift")).toBe("");
runPendingFrame();
enter(row);
expect(label.style.getPropertyValue("--hover-marquee-shift")).toBe("-140px");
expect(label.style.getPropertyValue("--hover-marquee-duration")).toBe("1750ms");
vi.advanceTimersByTime(499);
expect(label.classList.contains("hover-marquee--scrolling")).toBe(false);
vi.advanceTimersByTime(1);
expect(label.classList.contains("hover-marquee--scrolling")).toBe(true);
stopHoverMarquee(row);
leave(row);
expect(label.classList.contains("hover-marquee--scrolling")).toBe(false);
});
it("cancels the delayed scroll when hover ends early", () => {
const { row, label } = buildRow({ textWidth: 320, labelWidth: 180 });
startHoverMarquee(row);
runPendingFrame();
enter(row);
vi.advanceTimersByTime(250);
stopHoverMarquee(row);
leave(row);
vi.advanceTimersByTime(250);
expect(label.classList.contains("hover-marquee--scrolling")).toBe(false);
});
it("keeps the original delay when start repeats during hover", () => {
const { row, label } = buildRow({ textWidth: 320, labelWidth: 180 });
startHoverMarquee(row);
runPendingFrame();
vi.advanceTimersByTime(250);
startHoverMarquee(row);
runPendingFrame();
vi.advanceTimersByTime(250);
expect(label.classList.contains("hover-marquee--scrolling")).toBe(true);
});
it("cancels measurement when hover ends before the next frame", () => {
const { row, label } = buildRow({ textWidth: 320, labelWidth: 180 });
startHoverMarquee(row);
stopHoverMarquee(row);
runPendingFrame();
vi.advanceTimersByTime(500);
expect(label.style.getPropertyValue("--hover-marquee-shift")).toBe("");
expect(label.classList.contains("hover-marquee--scrolling")).toBe(false);
});
it("keeps short scroll distances readable with a minimum duration", () => {
const { row, label } = buildRow({ textWidth: 190, labelWidth: 180 });
startHoverMarquee(row);
runPendingFrame();
enter(row);
expect(label.style.getPropertyValue("--hover-marquee-shift")).toBe("-10px");
expect(label.style.getPropertyValue("--hover-marquee-duration")).toBe("300ms");
});
it("uses a clipping ancestor's content width", () => {
const { row, viewport, label } = buildRow({ textWidth: 190, labelWidth: 220 });
viewport.style.overflowX = "hidden";
viewport.style.paddingRight = "44px";
Object.defineProperty(viewport, "clientWidth", { value: 220 });
startHoverMarquee(row);
runPendingFrame();
expect(label.style.getPropertyValue("--hover-marquee-shift")).toBe("-14px");
});
it("leaves labels that fit untouched", () => {
const { row, label } = buildRow({ textWidth: 120, labelWidth: 180 });
startHoverMarquee(row);
runPendingFrame();
enter(row);
expect(label.classList.contains("hover-marquee--scrolling")).toBe(false);
expect(label.style.getPropertyValue("--hover-marquee-shift")).toBe("");
});
@@ -114,8 +71,63 @@ describe("hover marquee", () => {
it("ignores hosts without a marquee label", () => {
const row = document.createElement("div");
expect(() => {
startHoverMarquee(row);
stopHoverMarquee(row);
enter(row);
leave(row);
}).not.toThrow();
});
it("remeasures an active marquee when its available width changes", () => {
let resizeCallback: ResizeObserverCallback | undefined;
class TestResizeObserver implements ResizeObserver {
constructor(callback: ResizeObserverCallback) {
resizeCallback = callback;
}
observe() {}
unobserve() {}
disconnect() {}
}
const callbackObserver: ResizeObserver = {
observe() {},
unobserve() {},
disconnect() {},
};
vi.stubGlobal("ResizeObserver", TestResizeObserver);
let labelWidth = 180;
const row = document.createElement("div");
row.className = "session-row-host";
Object.defineProperty(row, "matches", {
value: (selector: string) => selector === ":hover",
});
const label = document.createElement("span");
label.className = "hover-marquee";
label.textContent = "Fix stale iMessage group-allowlist warning copy";
row.append(label);
document.body.append(row);
Object.defineProperty(label, "clientWidth", { get: () => labelWidth });
Object.defineProperty(label, "scrollWidth", { value: 320 });
enter(row);
vi.advanceTimersByTime(500);
expect(label.style.getPropertyValue("--hover-marquee-shift")).toBe("-140px");
expect(label.classList.contains("hover-marquee--scrolling")).toBe(true);
labelWidth = 120;
const resizeEntry = {
target: label,
borderBoxSize: [],
contentBoxSize: [],
contentRect: label.getBoundingClientRect(),
devicePixelContentBoxSize: [],
} satisfies ResizeObserverEntry;
if (!resizeCallback) {
throw new Error("Expected the marquee to observe its label");
}
resizeCallback([resizeEntry], callbackObserver);
expect(label.style.getPropertyValue("--hover-marquee-shift")).toBe("-200px");
expect(label.classList.contains("hover-marquee--scrolling")).toBe(false);
vi.advanceTimersByTime(500);
expect(label.classList.contains("hover-marquee--scrolling")).toBe(true);
});
});
+74 -79
View File
@@ -6,9 +6,8 @@
const MARQUEE_SPEED_PX_PER_SEC = 80;
const MARQUEE_MIN_DURATION_MS = 300;
const MARQUEE_HOVER_DELAY_MS = 500;
type PendingMarquee = { frame: number; timer?: number };
const pendingMarquees = new WeakMap<HTMLElement, PendingMarquee>();
const pendingMarquees = new WeakMap<HTMLElement, number>();
let marqueeResizeObserver: ResizeObserver | undefined;
function findMarqueeLabel(host: HTMLElement): HTMLElement | null {
return host.classList.contains("hover-marquee")
@@ -16,99 +15,83 @@ function findMarqueeLabel(host: HTMLElement): HTMLElement | null {
: host.querySelector<HTMLElement>(".hover-marquee");
}
function getMarqueeViewportWidth(label: HTMLElement, host: HTMLElement): number {
let width = label.clientWidth;
for (
let ancestor = label.parentElement;
ancestor && ancestor !== host;
ancestor = ancestor.parentElement
) {
const style = getComputedStyle(ancestor);
if (style.overflowX !== "hidden" && style.overflowX !== "clip") {
continue;
}
const padding =
(Number.parseFloat(style.paddingLeft) || 0) + (Number.parseFloat(style.paddingRight) || 0);
width = Math.min(width, Math.max(0, ancestor.clientWidth - padding));
}
return width;
}
function clearPendingMarquee(label: HTMLElement): void {
const pending = pendingMarquees.get(label);
if (pending === undefined) {
return;
}
window.cancelAnimationFrame(pending.frame);
if (pending.timer !== undefined) {
window.clearTimeout(pending.timer);
}
window.clearTimeout(pending);
pendingMarquees.delete(label);
}
export function startHoverMarquee(host: HTMLElement): void {
const label = findMarqueeLabel(host);
if (
!label ||
label.classList.contains("hover-marquee--scrolling") ||
pendingMarquees.has(label)
) {
return;
function observeMarquee(label: HTMLElement): void {
if (!marqueeResizeObserver && typeof ResizeObserver === "function") {
// Row endcaps can resize an adopted title without replacing its label.
// Remeasure the active animation so presence and badge changes cannot clip it.
marqueeResizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
if (!(entry.target instanceof HTMLElement)) {
continue;
}
const resizedLabel = entry.target;
const host = resizedLabel.closest<HTMLElement>(".session-row-host");
if (!host?.matches(":hover")) {
marqueeResizeObserver?.unobserve(resizedLabel);
continue;
}
clearPendingMarquee(resizedLabel);
resizedLabel.classList.remove("hover-marquee--scrolling");
startHoverMarquee(host);
}
});
}
// Catalog renders can reconnect refs while the pointer stays on the row.
// Preserve this label's delay; keyed label replacements get fresh state.
// Mouseenter fires before hover-only actions finish affecting layout. Measure
// on the next frame so the marquee sees the width the user actually sees.
const pending: PendingMarquee = {
frame: window.requestAnimationFrame(() => {
if (pendingMarquees.get(label) !== pending) {
return;
}
// A negative mid-transition indent (re-hover while snapping back) shrinks
// scrollWidth; add it back when calculating the clipped distance.
const indent = Number.parseFloat(getComputedStyle(label).textIndent) || 0;
const shift = label.scrollWidth - indent - getMarqueeViewportWidth(label, host);
if (shift <= 1) {
pendingMarquees.delete(label);
return;
}
const durationMs = Math.max(
MARQUEE_MIN_DURATION_MS,
Math.round((shift / MARQUEE_SPEED_PX_PER_SEC) * 1000),
);
label.style.setProperty("--hover-marquee-shift", `${-shift}px`);
label.style.setProperty("--hover-marquee-duration", `${durationMs}ms`);
// Keep quick pointer passes quiet; leaving before the timer fires cancels it.
pending.timer = window.setTimeout(() => {
pendingMarquees.delete(label);
label.classList.add("hover-marquee--scrolling");
}, MARQUEE_HOVER_DELAY_MS);
}),
};
pendingMarquees.set(label, pending);
marqueeResizeObserver?.observe(label);
}
export function stopHoverMarquee(host: HTMLElement): void {
function startHoverMarquee(host: HTMLElement): void {
const label = findMarqueeLabel(host);
if (!label) {
return;
}
observeMarquee(label);
if (label.classList.contains("hover-marquee--scrolling")) {
return;
}
clearPendingMarquee(label);
// Measure at hover time: labels resize with the sidebar and with hover-only
// row actions, so a cached width would drift. A negative mid-transition
// indent (re-hover while snapping back) shrinks scrollWidth; add it back.
const indent = Number.parseFloat(getComputedStyle(label).textIndent) || 0;
const shift = label.scrollWidth - indent - label.clientWidth;
if (shift <= 1) {
label.style.removeProperty("--hover-marquee-shift");
label.style.removeProperty("--hover-marquee-duration");
return;
}
const durationMs = Math.max(
MARQUEE_MIN_DURATION_MS,
Math.round((shift / MARQUEE_SPEED_PX_PER_SEC) * 1000),
);
label.style.setProperty("--hover-marquee-shift", `${-shift}px`);
label.style.setProperty("--hover-marquee-duration", `${durationMs}ms`);
// Keep quick pointer passes quiet; leaving before the timer fires cancels it.
pendingMarquees.set(
label,
window.setTimeout(() => {
pendingMarquees.delete(label);
label.classList.add("hover-marquee--scrolling");
}, MARQUEE_HOVER_DELAY_MS),
);
}
function stopHoverMarquee(host: HTMLElement): void {
const label = findMarqueeLabel(host);
if (!label) {
return;
}
clearPendingMarquee(label);
label.classList.remove("hover-marquee--scrolling");
}
export function restartHoverMarqueeIfHovered(element: Element | undefined): void {
if (!(element instanceof HTMLElement)) {
return;
}
queueMicrotask(() => {
const host = element.isConnected
? element.closest<HTMLElement>(".session-row-host")
: undefined;
if (host?.matches(":hover")) {
startHoverMarquee(host);
}
});
marqueeResizeObserver?.unobserve(label);
}
export function startHoverMarqueeFromEvent(event: Event): void {
@@ -122,3 +105,15 @@ export function stopHoverMarqueeFromEvent(event: Event): void {
stopHoverMarquee(event.currentTarget);
}
}
export function restartHoverMarqueeIfHovered(element: Element | undefined): void {
if (!(element instanceof HTMLElement)) {
return;
}
queueMicrotask(() => {
const host = element.isConnected ? element.closest<HTMLElement>(".session-row-host") : null;
if (host?.matches(":hover")) {
startHoverMarquee(host);
}
});
}
@@ -27,7 +27,10 @@ describe("groupCatalogSessionsByProject", () => {
session("b-2", "/work/bravo"),
]);
expect(result.groups.map((group) => group.key)).toEqual(["/work/bravo", "/work/alpha"]);
expect(result.groups.map((group) => group.key)).toEqual([
"project:/work/bravo",
"project:/work/alpha",
]);
expect(result.groups.map((group) => group.label)).toEqual(["bravo", "alpha"]);
expect(result.groups[0]?.sessions.map((item) => item.threadId)).toEqual(["b-1", "b-2"]);
});
@@ -39,8 +42,18 @@ describe("groupCatalogSessionsByProject", () => {
]);
expect(result.groups).toMatchObject([
{ key: "custom:Release", label: "Release", sessions: [{ threadId: "grouped" }] },
{ key: "/work/openclaw", label: "openclaw", sessions: [{ threadId: "project" }] },
{
key: "custom:Release",
legacySectionKey: "custom:Release",
label: "Release",
sessions: [{ threadId: "grouped" }],
},
{
key: "project:/work/openclaw",
legacySectionKey: "/work/openclaw",
label: "openclaw",
sessions: [{ threadId: "project" }],
},
]);
});
@@ -50,7 +63,26 @@ describe("groupCatalogSessionsByProject", () => {
{ ...session("grouped", "/work/openclaw"), customGroup: "Release" },
]);
expect(result.groups.map((group) => group.key)).toEqual(["custom:Release", "/work/openclaw"]);
expect(result.groups.map((group) => group.key)).toEqual([
"custom:Release",
"project:/work/openclaw",
]);
});
it("keeps custom groups separate from project paths with the same key text", () => {
const result = groupCatalogSessionsByProject([
{ ...session("grouped"), customGroup: "repo" },
session("project", "custom:repo"),
]);
expect(result.groups).toMatchObject([
{ key: "custom:repo", sessions: [{ threadId: "grouped" }] },
{
key: "project:custom:repo",
legacySectionKey: "custom:repo",
sessions: [{ threadId: "project" }],
},
]);
});
it.each([
@@ -64,7 +96,7 @@ describe("groupCatalogSessionsByProject", () => {
]);
expect(result.groups).toHaveLength(1);
expect(result.groups[0]?.key).toBe(expectedProject);
expect(result.groups[0]?.key).toBe(`project:${expectedProject}`);
expect(result.groups[0]?.sessions.map((item) => item.threadId)).toEqual(["direct", "worktree"]);
});
@@ -81,13 +113,14 @@ describe("groupCatalogSessionsByProject", () => {
it.each([
[" /Users/dev/openclaw/// ", "/Users/dev/openclaw", "openclaw"],
["C:\\Users\\dev\\openclaw\\", "C:\\Users\\dev\\openclaw", "openclaw"],
])("normalizes %s to key %s with label %s", (cwd, expectedKey, expectedLabel) => {
])("normalizes %s to project %s with label %s", (cwd, expectedPath, expectedLabel) => {
const result = groupCatalogSessionsByProject([session("one", cwd)]);
expect(result.groups[0]).toMatchObject({
key: expectedKey,
key: `project:${expectedPath}`,
legacySectionKey: expectedPath,
label: expectedLabel,
title: expectedKey,
title: expectedPath,
});
});
});
@@ -114,7 +147,11 @@ describe("groupCatalogSessionsByPerson", () => {
{ ...session("one"), createdActor: { type: "human", id: "profile-ada", label: " " } },
]);
expect(result.groups[0]).toMatchObject({ key: "person:profile-ada", label: "profile-ada" });
expect(result.groups[0]).toMatchObject({
key: "person:profile-ada",
legacySectionKey: "person:profile-ada",
label: "profile-ada",
});
});
it("leaves unattributed sessions in the flat ungrouped tail", () => {
@@ -7,7 +7,11 @@ export function normalizeCatalogProjectGrouping(raw: unknown): CatalogProjectGro
}
type CatalogProjectGroup = {
kind: "custom" | "project" | "person";
key: string;
// Collapse ids predate the group-kind namespace. Read the old suffix until
// the next toggle migrates that section to its canonical id.
legacySectionKey?: string;
label: string;
title: string;
sessions: SessionCatalogSession[];
@@ -22,22 +26,25 @@ export function groupCatalogSessionsByProject(sessions: readonly SessionCatalogS
// order depend on the roster's sort.
const customGroups: CatalogProjectGroup[] = [];
const projectGroups: CatalogProjectGroup[] = [];
const groupsByPath = new Map<string, CatalogProjectGroup>();
const customGroupsByName = new Map<string, CatalogProjectGroup>();
const projectGroupsByPath = new Map<string, CatalogProjectGroup>();
const ungrouped: SessionCatalogSession[] = [];
for (const session of sessions) {
const customGroup = session.customGroup?.trim();
if (customGroup) {
const key = `custom:${customGroup}`;
let group = groupsByPath.get(key);
let group = customGroupsByName.get(customGroup);
if (!group) {
group = {
kind: "custom",
key,
legacySectionKey: key,
label: customGroup,
title: `Custom group: ${customGroup}`,
sessions: [],
};
groupsByPath.set(key, group);
customGroupsByName.set(customGroup, group);
customGroups.push(group);
}
group.sessions.push(session);
@@ -58,15 +65,17 @@ export function groupCatalogSessionsByProject(sessions: readonly SessionCatalogS
ungrouped.push(session);
continue;
}
let group = groupsByPath.get(projectPath);
let group = projectGroupsByPath.get(projectPath);
if (!group) {
group = {
key: projectPath,
kind: "project",
key: `project:${projectPath}`,
legacySectionKey: projectPath,
label: projectPath.split(/[\\/]/).at(-1) || projectPath,
title: projectPath,
sessions: [],
};
groupsByPath.set(projectPath, group);
projectGroupsByPath.set(projectPath, group);
projectGroups.push(group);
}
group.sessions.push(session);
@@ -95,7 +104,14 @@ export function groupCatalogSessionsByPerson(sessions: readonly SessionCatalogSe
let group = groupsById.get(key);
if (!group) {
const label = actor.label?.trim() || actor.id;
group = { key, label, title: `Created by ${label}`, sessions: [] };
group = {
kind: "person",
key,
legacySectionKey: key,
label,
title: `Created by ${label}`,
sessions: [],
};
groupsById.set(key, group);
}
group.sessions.push(session);
+67 -39
View File
@@ -2119,6 +2119,42 @@ openclaw-chat-session-rail {
}
}
.chat-session-rail__composer {
display: flex;
align-items: center;
gap: 6px;
flex: 0 0 auto;
padding: 10px 10px 11px 14px;
border-top: 1px solid var(--border);
background: color-mix(in srgb, var(--secondary) 40%, transparent);
}
.chat-session-rail__prompt {
min-width: 0;
flex: 1 1 auto;
}
.chat-session-rail__input {
width: 100%;
min-height: 34px;
padding: 7px 10px;
border: 1px solid var(--border);
border-radius: var(--radius-lg);
background: var(--card);
color: var(--text);
font: inherit;
font-size: 12.5px;
}
.chat-session-rail__input:focus {
border-color: var(--border-strong);
outline: none;
}
.chat-session-rail__input::placeholder {
color: var(--muted);
}
/* The docked form: a real column in the pane's flex row. It claims layout space
instead of painting over the thread, so nothing here may float, shadow, or
blur — those cues would read as an overlay again. */
@@ -5627,30 +5663,15 @@ td.data-table-key-col {
display: inline;
}
/* ── Session row primitives ──
Shared by the sidebar recents list and the chat session picker. Rows read
as plain text lines: attribute badges follow the title, and the trailing
aside stacks transient state, relative time, and management actions in one
grid cell, so hovering swaps state for actions without any layout shift. */
/* Session row primitives shared by sidebar recents and the chat session picker.
Reserve hover-action width without moving the row or hiding trailing state. */
.session-row-host {
/* One size and one stroke for every glyph the row draws. Seven different
values used to land on the same line, which read as broken alignment.
The row-scoped SVG rule below leaves other surfaces at their own sizes. */
/* One grid for everything in the trailing column. The 24px action target sets
the desktop pitch; 14px endcap glyphs use the remaining 10px as their gap
so both lines share one trailing axis. */
--row-action-size: 24px;
--row-glyph-gap: 10px;
/* What the title gives up so the actions have somewhere to land: both boxes
plus a hair of clearance from the ellipsis. */
--row-actions-reserve: calc(var(--row-action-size) * 2 + 3px);
--session-row-actions-reserve: 52px;
position: relative;
}
.session-row-host[data-session-row-action-count="1"] {
--row-actions-reserve: calc(var(--row-action-size) + 3px);
--session-row-actions-reserve: 28px;
}
.session-row-host--draft {
@@ -5722,21 +5743,23 @@ td.data-table-key-col {
pointer-events: none;
}
/* One 14px track per glyph puts the 16px PR icon, 11px ring, and 7px unread dot
each on the action-icon axis the endcap padding aligns to. Centring them as
one box held only for a single glyph: a PR icon ahead of the trailing dot
pushed it 3.5px inboard of the dot-only rows. 14px, not the 24px action-button
box, which padded a 7px dot with 8.5px a side and broke the 6px rhythm. */
.session-row-state {
display: inline-grid;
grid-auto-flow: column;
grid-auto-columns: 14px;
align-items: center;
justify-items: center;
gap: var(--row-glyph-gap, 6px);
gap: 6px;
pointer-events: none;
}
/* The 16px PR glyph inks 1px beyond its 14px track. Inset only that glyph so
the other trailing atoms keep their shared axis inside the clipped endcap. */
.session-row-state [data-session-pr-state] {
position: relative;
left: 1px;
}
.sidebar-session-fork-indicator {
display: inline-flex;
margin-right: 4px;
@@ -5757,7 +5780,7 @@ td.data-table-key-col {
.session-row-badges {
display: inline-flex;
align-items: center;
gap: var(--row-glyph-gap, 6px);
gap: 6px;
flex: 0 0 auto;
}
@@ -5817,14 +5840,16 @@ td.data-table-key-col {
.session-row-actions {
display: inline-flex;
align-items: center;
gap: 1px;
}
.session-action {
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--row-action-size);
height: var(--row-action-size);
width: 24px;
height: 24px;
padding: 0;
border: none;
border-radius: var(--radius-sm);
@@ -5832,9 +5857,13 @@ td.data-table-key-col {
color: var(--muted);
opacity: 0;
pointer-events: none;
transition:
opacity var(--duration-fast) ease,
background var(--duration-fast) ease,
color var(--duration-fast) ease;
}
.session-row-host svg {
.session-action svg {
width: 14px;
height: 14px;
stroke: currentColor;
@@ -5867,21 +5896,20 @@ td.data-table-key-col {
pointer-events: auto;
}
/* Nothing in the row trades places with the actions any more. Two-line rows put
them on the title line; single-line rows reserve their width in the text
column, which slides the endcap clear. Fading state here used to prevent an
overlap that no longer happens, and it took the unread dot with it the moment
the pointer arrived — while the badges beside it stayed, which read as a
glitch rather than a deliberate swap. */
/* Sidebar rows are now the direct management surface after removing the
duplicate picker; touch users need pin/menu controls without hover. */
@media (hover: none), (pointer: coarse) {
.sidebar-recent-session.session-row-host {
--row-action-size: 44px;
--session-row-actions-reserve: 92px;
}
.sidebar-recent-session.session-row-host[data-session-row-action-count="1"] {
--session-row-actions-reserve: 48px;
}
.sidebar-recent-session .session-action {
width: 44px;
height: 44px;
opacity: 1;
pointer-events: auto;
}
@@ -5898,8 +5926,8 @@ td.data-table-key-col {
.session-run-spinner {
box-sizing: border-box;
display: inline-block;
width: 11px;
height: 11px;
width: 12px;
height: 12px;
flex: 0 0 auto;
border: 1.5px solid color-mix(in srgb, var(--run) 28%, transparent);
border-top-color: var(--run);
+4 -28
View File
@@ -1773,8 +1773,6 @@ openclaw-settings-save-indicator:empty {
here so gateway threads and native coding catalogs cannot drift apart. */
.sidebar-recent-session {
--sidebar-child-session-toggle-width: max(34px, calc(36px * var(--control-ui-text-scale)));
/* Title line box, shared by the label's line-height and the overlaid actions:
both must resolve the same value or the buttons drift off the title. */
--sidebar-title-line-height: calc(18px * var(--control-ui-text-scale));
display: flex;
@@ -2079,9 +2077,6 @@ openclaw-settings-save-indicator:empty {
padding-right: 2px;
}
/* Actions ride the title line: on a single-line row that is the row centre, and
on a two-line row it keeps them off the subtitle. The buttons re-enable their
own pointer-events on hover/focus. */
.sidebar-recent-session:not(.sidebar-recent-session--child) > .sidebar-recent-session__aside {
position: absolute;
inset: 50% 2px auto auto;
@@ -2089,8 +2084,6 @@ openclaw-settings-save-indicator:empty {
pointer-events: none;
}
/* Title line midpoint = the link's top padding plus half the title's line box.
Deriving it keeps the buttons on the title when either value changes. */
.sidebar-recent-session:not(.sidebar-recent-session--child, .sidebar-recent-session--single-line)
> .sidebar-recent-session__link {
padding-block: 1px;
@@ -2118,21 +2111,17 @@ openclaw-settings-save-indicator:empty {
right: calc(var(--sidebar-child-session-toggle-width) + 4px);
}
/* Only the title yields width to the actions. The second line keeps subtitle,
badges, state and time visible, which is the point of moving the buttons up. */
.sidebar-recent-session:not(
.sidebar-recent-session--child,
.sidebar-recent-session--single-line
):is(:hover, :focus-within)
.sidebar-recent-session__title-row {
padding-right: var(--row-actions-reserve);
padding-right: var(--session-row-actions-reserve);
}
/* Single-line rows put the endcap beside the title, so the reservation has to
cover the whole line there rather than the title alone. */
.sidebar-recent-session--single-line:not(.sidebar-recent-session--child):is(:hover, :focus-within)
.sidebar-recent-session__text {
padding-right: var(--row-actions-reserve);
padding-right: var(--session-row-actions-reserve);
}
.nav-collapse-toggle__icon {
@@ -2987,10 +2976,8 @@ wa-dropdown-item.session-menu__item::part(submenu-icon) {
inset: 50% 2px auto auto;
}
/* Always-visible 44px controls span a two-line row, so reserve their width
from the whole text column rather than letting them cover the endcap. */
.sidebar-recent-session:not(.sidebar-recent-session--child) .sidebar-recent-session__text {
padding-right: var(--row-actions-reserve);
padding-right: var(--session-row-actions-reserve);
}
.sidebar-recent-session:not(.sidebar-recent-session--child) .sidebar-recent-session__title-row {
@@ -4361,24 +4348,17 @@ html:not(.openclaw-native-macos):not(.openclaw-native-nav):not(.openclaw-native-
min-width: 0;
}
/* Action reservations on this viewport and the single-line text column are
deliberately not transitioned: the marquee measures their clipping width
on the next frame, so an animated width would make it scroll short. */
.sidebar-recent-session__title-row {
display: flex;
align-items: center;
min-width: 0;
}
/* Desktop action hitboxes belong entirely to the title line. Grow that line to
the 24px target and reclaim the space from the link padding above. */
.sidebar-recent-session:not(.sidebar-recent-session--child, .sidebar-recent-session--single-line)
.sidebar-recent-session__title-row {
height: max(24px, var(--sidebar-title-line-height));
}
/* Single-line rows lay the text column out in a row, so the title box has to
absorb the shrink there the way the bare label used to. */
.sidebar-recent-session--single-line .sidebar-recent-session__title-row {
flex: 1 1 0;
}
@@ -4423,17 +4403,13 @@ html:not(.openclaw-native-macos):not(.openclaw-native-nav):not(.openclaw-native-
color: var(--muted);
}
/* The endcap shares the trailing column with the action icons on the line
above, and those sit inset inside their hover targets, so it borrows the same
inset rather than running flush to the row edge. */
.sidebar-recent-session__details-endcap {
display: inline-flex;
align-items: center;
gap: var(--row-glyph-gap, 6px);
gap: 6px;
max-width: 100%;
height: 18px;
min-width: 0;
padding-right: calc((var(--row-action-size) - 14px) / 2);
overflow: hidden;
flex: 0 1 auto;
margin-left: auto;
@@ -4,6 +4,183 @@ import { createGateway, createSessions, mountSidebar } from "../app-sidebar.ts";
import "../../components/app-sidebar.ts";
describe("AppSidebar project session activity", () => {
it("preserves collapsed project sections stored by earlier versions", async () => {
localStorage.setItem(
"openclaw:sidebar:sessions:collapsed-sections",
JSON.stringify(["catalog-project:codex:gateway:local:custom:repo"]),
);
const gateway = createGateway({} as GatewayBrowserClient);
const { sidebar } = await mountSidebar(gateway, createSessions("main", ["agent:main:main"]));
sidebar.sessionData.sessionCatalogs = [
{
id: "codex",
label: "Codex",
capabilities: { continueSession: true, archive: true },
hosts: [
{
hostId: "gateway:local",
label: "Local Codex",
kind: "gateway",
connected: true,
sessions: [
{
threadId: "custom-group-thread",
name: "Custom group session",
customGroup: "repo",
status: "idle",
archived: false,
canContinue: true,
canArchive: true,
},
{
threadId: "legacy-project-thread",
name: "Legacy collapsed project",
cwd: "custom:repo",
status: "idle",
archived: false,
canContinue: true,
canArchive: true,
},
],
},
],
},
];
sidebar.sessionData.requestSessionDataUpdate();
await sidebar.updateComplete;
const customGroup = sidebar.querySelector<HTMLButtonElement>(
'[data-session-catalog-project="custom:repo"]',
);
const project = sidebar.querySelector<HTMLButtonElement>(
'[data-session-catalog-project="project:custom:repo"]',
);
expect(customGroup?.getAttribute("aria-expanded")).toBe("false");
expect(project?.getAttribute("aria-expanded")).toBe("false");
expect(sidebar.querySelector('[data-session-key*="custom-group-thread"]')).toBeNull();
expect(sidebar.querySelector('[data-session-key*="legacy-project-thread"]')).toBeNull();
project?.click();
await sidebar.updateComplete;
expect(customGroup?.getAttribute("aria-expanded")).toBe("true");
expect(
JSON.parse(localStorage.getItem("openclaw:sidebar:sessions:collapsed-sections") ?? "[]"),
).not.toContain("catalog-project:codex:gateway:local:custom:repo");
project?.click();
await sidebar.updateComplete;
customGroup?.click();
await sidebar.updateComplete;
expect(project?.getAttribute("aria-expanded")).toBe("false");
expect(customGroup?.getAttribute("aria-expanded")).toBe("false");
expect(
JSON.parse(localStorage.getItem("openclaw:sidebar:sessions:collapsed-sections") ?? "[]"),
).toEqual([
"catalog-project:codex:gateway:local:project:custom:repo",
"catalog-custom:codex:gateway:local:custom:repo",
]);
});
it("preserves and migrates collapsed person sections stored by earlier versions", async () => {
localStorage.setItem("openclaw:sidebar:sessions:catalog-grouping", "person");
const legacySectionId = "catalog-project:codex:gateway:local:person:profile-ada";
localStorage.setItem(
"openclaw:sidebar:sessions:collapsed-sections",
JSON.stringify([legacySectionId]),
);
const gateway = createGateway({} as GatewayBrowserClient);
const { sidebar } = await mountSidebar(gateway, createSessions("main", ["agent:main:main"]));
sidebar.sessionData.sessionCatalogs = [
{
id: "codex",
label: "Codex",
capabilities: { continueSession: true, archive: true },
hosts: [
{
hostId: "gateway:local",
label: "Local Codex",
kind: "gateway",
connected: true,
sessions: [
{
threadId: "person-thread",
name: "Ada's session",
createdActor: { type: "human", id: "profile-ada", label: "Ada" },
status: "idle",
archived: false,
canContinue: true,
canArchive: true,
},
],
},
],
},
];
sidebar.sessionData.requestSessionDataUpdate();
await sidebar.updateComplete;
const person = sidebar.querySelector<HTMLButtonElement>(
'[data-session-catalog-project="person:profile-ada"]',
);
expect(person?.getAttribute("aria-expanded")).toBe("false");
expect(sidebar.querySelector('[data-session-key*="person-thread"]')).toBeNull();
person?.click();
await sidebar.updateComplete;
expect(person?.getAttribute("aria-expanded")).toBe("true");
person?.click();
await sidebar.updateComplete;
expect(
JSON.parse(localStorage.getItem("openclaw:sidebar:sessions:collapsed-sections") ?? "[]"),
).toEqual(["catalog-person:codex:gateway:local:person:profile-ada"]);
});
it("preserves catalog menu focus when project groups reorder", async () => {
const gateway = createGateway({} as GatewayBrowserClient);
const { sidebar } = await mountSidebar(gateway, createSessions("main", ["agent:main:main"]));
const sessions = [
{ threadId: "thread-a", name: "Project A", cwd: "/work/a" },
{ threadId: "thread-b", name: "Project B", cwd: "/work/b" },
];
const setCatalog = async (orderedSessions: typeof sessions) => {
sidebar.sessionData.sessionCatalogs = [
{
id: "codex",
label: "Codex",
capabilities: { continueSession: true, archive: true },
hosts: [
{
hostId: "gateway:local",
label: "Local Codex",
kind: "gateway",
connected: true,
sessions: orderedSessions.map((session) => ({
...session,
status: "idle" as const,
archived: false,
canContinue: true,
canArchive: true,
})),
},
],
},
];
sidebar.sessionData.requestSessionDataUpdate();
await sidebar.updateComplete;
};
await setCatalog(sessions);
const menu = sidebar.querySelector<HTMLButtonElement>(
'[data-session-key*="thread-a"] [data-catalog-session-menu]',
);
menu?.focus();
expect(document.activeElement).toBe(menu);
await setCatalog(sessions.toReversed());
expect(document.activeElement).toBe(menu);
});
it("shows thread-style activity indicators", async () => {
const gateway = createGateway({} as GatewayBrowserClient);
const { sidebar } = await mountSidebar(gateway, createSessions("main", ["agent:main:main"]));
@@ -53,7 +230,9 @@ describe("AppSidebar project session activity", () => {
sidebar.sessionData.requestSessionDataUpdate();
await sidebar.updateComplete;
const project = sidebar.querySelector('[data-session-catalog-project="/work/openclaw"]');
const project = sidebar.querySelector(
'[data-session-catalog-project="project:/work/openclaw"]',
);
const active = sidebar.querySelector('[data-session-key*="active-thread"]');
const idle = sidebar.querySelector('[data-session-key*="idle-thread"]');
const loose = sidebar.querySelector('[data-session-key*="loose-thread"]');
@@ -1,164 +0,0 @@
import { describe, expect, it } from "vitest";
import type { GatewayBrowserClient } from "../../api/gateway.ts";
import {
catalogPage,
createGateway,
createSessions,
createSessionsHarness,
mountSidebar,
} from "../app-sidebar.ts";
import "../../components/app-sidebar.ts";
describe("AppSidebar session catalog row identity", () => {
it("does not carry marquee state across material updates or replacements", async () => {
const gateway = createGateway({} as GatewayBrowserClient);
const { sidebar } = await mountSidebar(gateway, createSessions("main", ["agent:main:main"]));
sidebar.sessionData.sessionCatalogs = catalogPage([
{ threadId: "thread-first", name: "First catalog session" },
{ threadId: "thread-second", name: "Second catalog session" },
]).catalogs;
sidebar.sessionData.requestSessionDataUpdate();
await sidebar.updateComplete;
const firstRow = sidebar.querySelector<HTMLElement>(
'[data-session-section="catalog:codex"] [data-session-key$=":thread-first"]',
);
const firstLabel = firstRow?.querySelector<HTMLElement>(".hover-marquee");
const firstMenu = firstRow?.querySelector<HTMLButtonElement>("[data-catalog-session-menu]");
firstLabel?.classList.add("hover-marquee--scrolling");
firstLabel?.style.setProperty("--hover-marquee-shift", "-80px");
firstMenu?.focus();
expect(document.activeElement).toBe(firstMenu);
sidebar.sessionData.sessionCatalogs = catalogPage([
{ threadId: "thread-second", name: "Second catalog session" },
{ threadId: "thread-first", name: "Renamed catalog session" },
]).catalogs;
sidebar.sessionData.requestSessionDataUpdate();
await sidebar.updateComplete;
const updatedRow = sidebar.querySelector<HTMLElement>(
'[data-session-section="catalog:codex"] [data-session-key$=":thread-first"]',
);
const updatedLabel = updatedRow?.querySelector<HTMLElement>(".hover-marquee");
const updatedMenu = updatedRow?.querySelector<HTMLButtonElement>("[data-catalog-session-menu]");
expect(updatedRow).toBe(firstRow);
expect(updatedLabel).not.toBe(firstLabel);
expect(updatedMenu).toBe(firstMenu);
expect(document.activeElement).toBe(updatedMenu);
expect(updatedLabel?.classList.contains("hover-marquee--scrolling")).toBe(false);
expect(updatedLabel?.style.getPropertyValue("--hover-marquee-shift")).toBe("");
updatedLabel?.classList.add("hover-marquee--scrolling");
updatedLabel?.style.setProperty("--hover-marquee-shift", "-60px");
sidebar.sessionData.sessionCatalogs = catalogPage([
{ threadId: "thread-third", name: "Replacement catalog session" },
]).catalogs;
sidebar.sessionData.requestSessionDataUpdate();
await sidebar.updateComplete;
const replacementRow = sidebar.querySelector<HTMLElement>(
'[data-session-section="catalog:codex"] .sidebar-recent-session',
);
const replacementLabel = replacementRow?.querySelector<HTMLElement>(".hover-marquee");
expect(replacementRow).not.toBe(updatedRow);
expect(replacementLabel?.classList.contains("hover-marquee--scrolling")).toBe(false);
expect(replacementLabel?.style.getPropertyValue("--hover-marquee-shift")).toBe("");
});
it("restores menu focus when a catalog thread is adopted", async () => {
const adoptedKey = "agent:main:adopted";
const gateway = createGateway({} as GatewayBrowserClient);
const { sidebar } = await mountSidebar(
gateway,
createSessions("main", ["agent:main:main", adoptedKey]),
);
sidebar.sessionData.sessionCatalogs = catalogPage([
{ threadId: "thread-adopted", name: "Catalog session" },
]).catalogs;
sidebar.sessionData.requestSessionDataUpdate();
await sidebar.updateComplete;
const catalogMenu = sidebar.querySelector<HTMLButtonElement>("[data-catalog-session-menu]");
catalogMenu?.focus();
expect(document.activeElement).toBe(catalogMenu);
sidebar.sessionData.sessionCatalogs = catalogPage([
{ threadId: "thread-adopted", name: "Catalog session", sessionKey: adoptedKey },
]).catalogs;
sidebar.sessionData.requestSessionDataUpdate();
await sidebar.updateComplete;
const adoptedMenu = sidebar.querySelector<HTMLButtonElement>(
`[data-session-key="${adoptedKey}"] [data-session-menu]`,
);
expect(document.activeElement).toBe(adoptedMenu);
});
it("resets an adopted marquee when its live pull request appears", async () => {
const adoptedKey = "agent:main:adopted-pull-request";
const gateway = createGateway({} as GatewayBrowserClient);
const sessions = createSessionsHarness("main", ["agent:main:main", adoptedKey]);
const { sidebar } = await mountSidebar(gateway, sessions.sessions);
sidebar.sessionData.sessionCatalogs = catalogPage([
{
threadId: "thread-adopted-pull-request",
name: "Adopted catalog session",
sessionKey: adoptedKey,
},
]).catalogs;
sidebar.sessionData.requestSessionDataUpdate();
await sidebar.updateComplete;
const row = sidebar.querySelector<HTMLElement>(`[data-session-key="${adoptedKey}"]`);
const label = row?.querySelector<HTMLElement>(".hover-marquee");
label?.classList.add("hover-marquee--scrolling");
label?.style.setProperty("--hover-marquee-shift", "-80px");
sessions.sessions.setPullRequestSummary(adoptedKey, { numbers: [125820], state: "open" });
await sidebar.updateComplete;
const updatedRow = sidebar.querySelector<HTMLElement>(`[data-session-key="${adoptedKey}"]`);
const updatedLabel = updatedRow?.querySelector<HTMLElement>(".hover-marquee");
expect(updatedRow).toBe(row);
expect(updatedLabel).not.toBe(label);
expect(updatedLabel?.classList.contains("hover-marquee--scrolling")).toBe(false);
expect(updatedLabel?.style.getPropertyValue("--hover-marquee-shift")).toBe("");
expect(updatedRow?.querySelector(".session-row-badge--pull-request")).not.toBeNull();
});
it("restores menu focus when an adopted catalog thread loses its session", async () => {
const adoptedKey = "agent:main:released";
const gateway = createGateway({} as GatewayBrowserClient);
const { sidebar } = await mountSidebar(
gateway,
createSessions("main", ["agent:main:main", adoptedKey]),
);
sidebar.sessionData.sessionCatalogs = catalogPage([
{
threadId: "thread-released",
name: "Adopted catalog session",
sessionKey: adoptedKey,
},
]).catalogs;
sidebar.sessionData.requestSessionDataUpdate();
await sidebar.updateComplete;
const adoptedMenu = sidebar.querySelector<HTMLButtonElement>(
`[data-session-key="${adoptedKey}"] [data-session-menu]`,
);
adoptedMenu?.focus();
expect(document.activeElement).toBe(adoptedMenu);
sidebar.sessionData.sessionCatalogs = catalogPage([
{ threadId: "thread-released", name: "Native catalog session" },
]).catalogs;
sidebar.sessionData.requestSessionDataUpdate();
await sidebar.updateComplete;
const nativeMenu = sidebar.querySelector<HTMLButtonElement>(
'[data-session-key$=":thread-released"] [data-catalog-session-menu]',
);
expect(document.activeElement).toBe(nativeMenu);
});
});
@@ -0,0 +1,93 @@
import { describe, expect, it } from "vitest";
import type { GatewayBrowserClient } from "../../api/gateway.ts";
import {
catalogPage,
createGateway,
createSessions,
createSessionsHarness,
mountSidebar,
} from "../app-sidebar.ts";
import "../../components/app-sidebar.ts";
describe("AppSidebar catalog row lifecycle", () => {
it("retargets an open menu when its row is adopted", async () => {
const adoptedKey = "agent:main:adopted-menu";
const gateway = createGateway({} as GatewayBrowserClient);
const { sidebar } = await mountSidebar(
gateway,
createSessions("main", ["agent:main:main", adoptedKey]),
);
const setCatalog = async (sessionKey?: string) => {
sidebar.sessionData.sessionCatalogs = catalogPage([
{ threadId: "thread-adopted-menu", name: "Adopted menu", sessionKey },
]).catalogs;
sidebar.sessionData.requestSessionDataUpdate();
await sidebar.updateComplete;
};
await setCatalog();
sidebar.querySelector<HTMLButtonElement>("[data-catalog-session-menu]")?.click();
await sidebar.updateComplete;
await setCatalog(adoptedKey);
await Promise.resolve();
await sidebar.updateComplete;
const adoptedMenu = sidebar.querySelector<HTMLButtonElement>(
`[data-session-key="${adoptedKey}"] [data-session-menu]`,
);
const popup = sidebar.querySelector<HTMLElement & { trigger?: HTMLElement }>(
"openclaw-catalog-session-menu",
);
expect(adoptedMenu?.getAttribute("aria-expanded")).toBe("true");
expect(popup?.trigger).toBe(adoptedMenu);
popup?.querySelector<HTMLElement>("wa-dropdown-item")?.focus();
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
await sidebar.updateComplete;
expect(document.activeElement).toBe(adoptedMenu);
});
it("clears marquee state when a catalog label changes", async () => {
const gateway = createGateway({} as GatewayBrowserClient);
const { sidebar } = await mountSidebar(gateway, createSessions("main", ["agent:main:main"]));
const setLabel = async (name: string) => {
sidebar.sessionData.sessionCatalogs = catalogPage([
{ threadId: "thread-rename", name },
]).catalogs;
sidebar.sessionData.requestSessionDataUpdate();
await sidebar.updateComplete;
};
await setLabel("A long catalog session title");
const oldLabel = sidebar.querySelector<HTMLElement>(".hover-marquee");
oldLabel?.classList.add("hover-marquee--scrolling");
oldLabel?.style.setProperty("--hover-marquee-shift", "-80px");
await setLabel("Short");
const updatedLabel = sidebar.querySelector<HTMLElement>(".hover-marquee");
expect(updatedLabel).not.toBe(oldLabel);
expect(updatedLabel?.classList.contains("hover-marquee--scrolling")).toBe(false);
expect(updatedLabel?.style.getPropertyValue("--hover-marquee-shift")).toBe("");
});
it("clears adopted marquee state when its live pull request appears", async () => {
const adoptedKey = "agent:main:adopted-pull-request";
const gateway = createGateway({} as GatewayBrowserClient);
const sessions = createSessionsHarness("main", ["agent:main:main", adoptedKey]);
const { sidebar } = await mountSidebar(gateway, sessions.sessions);
sidebar.sessionData.sessionCatalogs = catalogPage([
{ threadId: "thread-adopted-pr", name: "Adopted session", sessionKey: adoptedKey },
]).catalogs;
sidebar.sessionData.requestSessionDataUpdate();
await sidebar.updateComplete;
const row = sidebar.querySelector<HTMLElement>(`[data-session-key="${adoptedKey}"]`);
const oldLabel = row?.querySelector<HTMLElement>(".hover-marquee");
oldLabel?.classList.add("hover-marquee--scrolling");
oldLabel?.style.setProperty("--hover-marquee-shift", "-80px");
sessions.sessions.setPullRequestSummary(adoptedKey, { numbers: [125820], state: "open" });
await sidebar.updateComplete;
const updatedLabel = row?.querySelector<HTMLElement>(".hover-marquee");
expect(updatedLabel).not.toBe(oldLabel);
expect(updatedLabel?.classList.contains("hover-marquee--scrolling")).toBe(false);
expect(row?.querySelector(".session-row-badge--pull-request")).not.toBeNull();
});
});