From dea053e32f4cca1207f980d643f7c6ef795fd9f9 Mon Sep 17 00:00:00 2001 From: Jesse Merhi <79823012+jesse-merhi@users.noreply.github.com> Date: Mon, 24 Aug 2026 23:40:26 +1000 Subject: [PATCH] improve(ui): stop sidebar rows rearranging themselves on hover (#125820) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * improve(ui): put session row actions on the title line Two-line sidebar rows centred the pin and menu buttons across the whole row, so they floated between the title and the line below, and hover reserved their width from the entire text column while fading the status endcap out. Hovering a row therefore truncated the subtitle and hid its badges, spinner, unread dot and relative time — the row rearranged itself under the pointer just to show two controls. The buttons now anchor to the title line, derived from the link's top padding plus half the title's line box, and only the title yields their width. The second line keeps its content and its icons. Single-line rows are untouched: their one line already is the title line, so #126455's centring and the endcap beside the title stay exactly as they are. The reservation is deliberately not transitioned: startHoverMarquee measures clientWidth synchronously on mouseenter, so an animated width would make long titles scroll short and would silence titles that only overflow once the buttons are up. * fix(ui): keep second-line state lit while the actions are up Hovering a two-line row to reach the pin or menu also faded its trailing state out, so the run spinner disappeared under the pointer — the row stopped telling you it was still working at the moment you reached for its controls. That trade existed because the actions used to cover the endcap. On a two-line row they now sit on the title line and cover nothing, so the second line keeps its spinner, unread dot, badges and time. Single-line rows still trade: there the endcap genuinely shares the actions' line. * fix(ui): stop hiding row state and clipping it with the actions Two problems the first pass left behind, both visible on hover: The unread dot vanished the moment the pointer arrived on a single-line row. That row reserves the action width in its text column, which already slides the endcap clear, but the old fade still ran — so the dot disappeared while the badges beside it stayed, reading as a glitch rather than a swap. Nothing trades places with the actions now, on either row shape. A 24px control centred on the 18px title line also hung 3px into the row below, so a hovered button's fill would clip the top of the badges that now stay lit there. Two-line rows shrink the control to the line it sits on; the 14px glyph is unchanged and coarse pointers keep the full 24px target. Measured on the preview: single-line endcap ends at 183 with the first action at 186, and the action box now ends exactly where the second line begins. * fix(ui): even out endcap spacing and drop the touch swap leftovers The unread dot sat further from the badges than they sat from each other: 6px between the badges, 14.5px before the dot. The state box carried min-width and min-height of 24px, sized to match the action button it used to trade places with, so a 7px dot got 8.5px of padding a side. Nothing trades now, so the box sizes to its glyphs and the endcap keeps one 6px rhythm. The touch block kept the other half of that arrangement: state and actions sharing a grid cell, and the relative-time trail permanently hidden. Both go the same way — on touch the second line now keeps its content like everywhere else. Covered by a case whose state holds a single dot, which is the only shape where the padded box was visible; it reports [6,14.5] against the old CSS. * fix(ui): draw every session row glyph at one size The row drew seven different glyph sizes on the same two lines: archive 11px, badges and spinner 12px, board 13px, fork and the action icons 14px, attention 15px, with stroke widths of 1.6, 1.7 and 1.8 mixed among them. Side by side on one line that reads as broken alignment rather than a hierarchy. They now resolve one --session-row-glyph-size and one --session-row-glyph-stroke declared on .session-row-host, so the whole row moves together and a new glyph inherits the rhythm instead of picking its own. Every rule keeps its previous value as the var fallback, so the same components rendered outside a session row are untouched. The unread dot stays 7px: it is a dot rather than a glyph, and matching it to a 14px line icon would turn it into a blob. A test walks a row carrying badges, fork provenance, a spinner and the action icons and asserts one distinct size across them; against the old CSS it reports ["14x14","12x12"]. * fix(ui): size the session row run ring optically The ring inks its whole box while the line icons beside it only ink 9-10px of their 14px box, so unifying it to the glyph token made it read a size larger than the pin and menu glyphs on the same line. Give the row host its own ring token three px down from the glyph size; surfaces outside a row keep the ring's previous 12px. * fix(ui): put the whole session row trailing column on one grid The action buttons stepped 25px inside 24px hover targets while the endcap's badges stepped 20px and ran flush to the row edge, so a badge, dot or ring never landed under the icon above it. Derive both gaps and the endcap's inset from the action box, and give the state group a glyph-wide floor so bare shapes centre on the same axis as icons drawn in a box. * fix(ui): tighten the session row trailing column Matching the endcap to a 24px action box spaced the badges 11px apart, which read airier than before the grid landed. Take the action box to 20px so the pitch drops to 21 and the badges return to a 6px-ish gap, derive the title's hover reservation from that box instead of a fixed 52px, and let touch keep the square button on two-line rows now that no hover fill can clip the badges. * fix(ui): type the session row test fixtures and endcap lookups check:test-types caught three errors the ui lane does not cover: the badge fixtures pass hasAutomation and incognito, which the sessionRow helper never declared, and the endcap gap probe indexed a possibly-empty NodeList. * fix(ui): repair session row hover review findings * fix(ui): preserve catalog sidebar interaction state * fix(ui): preserve catalog interaction state during rerenders * perf(ui): keep session rows within CSS budget * test(ui): satisfy immutable array lint * fix(ui): preserve catalog identity across session release * fix(ui): remeasure adopted row live state --- .../app-sidebar-catalog-menu.test.ts | 23 + ui/src/components/app-sidebar-catalog-menu.ts | 15 + .../app-sidebar-session-catalog-render.ts | 206 ++++++-- .../app-sidebar-session-catalogs.ts | 6 + .../app-sidebar-session-list-render.ts | 1 + .../app-sidebar-session-row-render.ts | 130 +++-- ui/src/components/app-sidebar.test.ts | 1 + ui/src/components/app-sidebar.ts | 5 + .../components/form-controls.browser.test.ts | 9 + ...chat-flow.sidebar-presentation.e2e.test.ts | 456 ++++++++++++++++- ui/src/e2e/codex-sessions.e2e.test.ts | 31 +- .../e2e/session-catalog-identity.e2e.test.ts | 176 +++++++ ui/src/e2e/session-management.test-support.ts | 34 ++ ...sion-management.trailing-state.e2e.test.ts | 479 ++++++++++++++++-- ui/src/lib/hover-marquee.test.ts | 60 ++- ui/src/lib/hover-marquee.ts | 113 ++++- ui/src/styles/components.css | 98 ++-- ui/src/styles/layout.css | 117 ++++- .../app-sidebar-cases/catalog-row-keying.ts | 164 ++++++ ui/src/test-helpers/app-sidebar.ts | 2 +- 20 files changed, 1878 insertions(+), 248 deletions(-) create mode 100644 ui/src/e2e/session-catalog-identity.e2e.test.ts create mode 100644 ui/src/test-helpers/app-sidebar-cases/catalog-row-keying.ts diff --git a/ui/src/components/app-sidebar-catalog-menu.test.ts b/ui/src/components/app-sidebar-catalog-menu.test.ts index 96a13bd1a3a4..a45c64c2818b 100644 --- a/ui/src/components/app-sidebar-catalog-menu.test.ts +++ b/ui/src/components/app-sidebar-catalog-menu.test.ts @@ -32,4 +32,27 @@ 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(); + } + }); }); diff --git a/ui/src/components/app-sidebar-catalog-menu.ts b/ui/src/components/app-sidebar-catalog-menu.ts index 0325aac64900..647fb06cfc56 100644 --- a/ui/src/components/app-sidebar-catalog-menu.ts +++ b/ui/src/components/app-sidebar-catalog-menu.ts @@ -59,6 +59,21 @@ export class SidebarCatalogMenuController { this.hooks.requestUpdate(); } + retargetTrigger(key: CatalogSessionKey, element: Element | undefined): void { + 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. + queueMicrotask(() => { + if (!element.isConnected || this.trigger?.isConnected || !this.isOpenFor(key)) { + return; + } + this.trigger = element; + this.hooks.requestUpdate(); + }); + } + private handleAction( menu: SidebarCatalogSessionMenuState, action: CatalogSessionMenuAction, diff --git a/ui/src/components/app-sidebar-session-catalog-render.ts b/ui/src/components/app-sidebar-session-catalog-render.ts index 12f469899c5b..dddab02c4406 100644 --- a/ui/src/components/app-sidebar-session-catalog-render.ts +++ b/ui/src/components/app-sidebar-session-catalog-render.ts @@ -1,4 +1,7 @@ import { html, nothing } from "lit"; +import { keyed } from "lit/directives/keyed.js"; +import { ref } from "lit/directives/ref.js"; +import { repeat } from "lit/directives/repeat.js"; import type { SessionCatalog, SessionCatalogHost, @@ -10,6 +13,11 @@ import { withSidebarNavCollapseIntent } from "../app-session-route-paths.ts"; import type { ApplicationNavigationOptions } from "../app/context.ts"; import { t } from "../i18n/index.ts"; import { formatUiError } from "../lib/format-error.ts"; +import { + restartHoverMarqueeIfHovered, + startHoverMarqueeFromEvent, + stopHoverMarqueeFromEvent, +} from "../lib/hover-marquee.ts"; import { handleContextMenuEvent } from "../lib/keyboard-shortcuts.ts"; import { shouldHandleNavigationClick } from "../lib/navigation-click.ts"; import { isSessionRunActive } from "../lib/session-run-state.ts"; @@ -77,6 +85,7 @@ type SessionCatalogGroupsParams = { y: number, trigger?: HTMLElement, ) => void; + onCatalogMenuTriggerRendered: (key: CatalogSessionKey, element: Element | undefined) => void; isMenuOpen: (key: CatalogSessionKey) => boolean; }; @@ -297,6 +306,47 @@ 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, + 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, @@ -337,54 +387,58 @@ function renderCatalogHostGroup( : nothing} + `; + }, )} + ${renderCatalogSessionRows( + catalog, + host, + projectGroups.ungrouped, + liveRowsByKey, + params, + )}` + : renderCatalogSessionRows(catalog, host, host.sessions, liveRowsByKey, params)} `; @@ -401,21 +455,52 @@ function renderCatalogSessionRow( const timestamp = normalizeCatalogTimestamp( session.recencyAt ?? session.updatedAt ?? session.createdAt, ); - const adoptedRow = session.sessionKey ? liveRowsByKey.get(session.sessionKey) : undefined; - if (adoptedRow) { - const label = session.name || session.threadId; - return params.renderLiveRow(adoptedRow, { - label, - ...(session.pullRequest ? { pullRequest: session.pullRequest } : {}), - }); - } const catalogKey = { catalogId: catalog.id, hostId: host.hostId, threadId: session.threadId, } satisfies CatalogSessionKey; - const key = session.sessionKey ?? buildCatalogSessionKey(catalogKey); + const catalogMenuOpen = params.isMenuOpen(catalogKey); + const catalogMenuTriggerRef = catalogMenuOpen + ? (element: Element | undefined) => params.onCatalogMenuTriggerRendered(catalogKey, element) + : undefined; + const identityKey = catalogSessionIdentityKey(catalog, host, session); + const key = session.sessionKey ?? identityKey; + const focusedControl = + document.activeElement instanceof HTMLElement ? document.activeElement : undefined; + const focusedRow = focusedControl?.closest("[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 adoptedRow = session.sessionKey ? liveRowsByKey.get(session.sessionKey) : undefined; + if (adoptedRow) { + return params.renderLiveRow(adoptedRow, { + label, + catalogIdentityKey: identityKey, + marqueeKey: JSON.stringify([label, session.pullRequest]), + catalogMenuOpen, + ...(catalogMenuTriggerRef ? { catalogMenuTriggerRef } : {}), + ...(session.pullRequest ? { pullRequest: session.pullRequest } : {}), + ...(focusRef && focusedControlKind + ? { focusedControl: focusedControlKind, restoreControlFocus: focusRef } + : {}), + }); + } const meta = formatSidebarTimestamp(timestamp); const routeId = "chat"; const target = sessionNavigationTarget({ @@ -454,7 +539,16 @@ function renderCatalogSessionRow( : null, (trigger, x, y) => openMenu(x, y, trigger ?? undefined), ); - return html` + // Marquee state lives on the label; reset it without replacing focused row controls. + const marqueeLabel = keyed( + JSON.stringify([label, session.status, session.pullRequest]), + html`${label}`, + ); + const row = html` `; + return row; } diff --git a/ui/src/components/app-sidebar-session-catalogs.ts b/ui/src/components/app-sidebar-session-catalogs.ts index f7130b9997b4..d7600130978b 100644 --- a/ui/src/components/app-sidebar-session-catalogs.ts +++ b/ui/src/components/app-sidebar-session-catalogs.ts @@ -134,8 +134,14 @@ export function visibleCatalogHosts( export type CatalogBackingSessionDisplay = { label: string; + catalogIdentityKey: string; + marqueeKey?: string; + catalogMenuOpen?: boolean; + catalogMenuTriggerRef?: (element: Element | undefined) => void; subtitle?: string; pullRequest?: SessionCatalogSession["pullRequest"]; + focusedControl?: "link" | "child-toggle" | "pin" | "menu"; + restoreControlFocus?: (element: Element | undefined) => void; }; export type CatalogSessionMenuRequest = { diff --git a/ui/src/components/app-sidebar-session-list-render.ts b/ui/src/components/app-sidebar-session-list-render.ts index d93c3b9f226c..4225b8f0c8bf 100644 --- a/ui/src/components/app-sidebar-session-list-render.ts +++ b/ui/src/components/app-sidebar-session-list-render.ts @@ -352,6 +352,7 @@ function renderSessionCatalog(params: { terminalAvailable: snapshot.terminalAvailable, onOpenTerminal: openCatalogSessionInTerminal, onOpenMenu: (request, x, y, trigger) => host.openCatalogMenu(request, x, y, trigger), + onCatalogMenuTriggerRendered: (key, element) => host.retargetCatalogMenuTrigger(key, element), isMenuOpen: (key) => host.sidebarMenus.catalogMenu.isOpenFor(key), })} `; diff --git a/ui/src/components/app-sidebar-session-row-render.ts b/ui/src/components/app-sidebar-session-row-render.ts index 50db37721371..ad8294152537 100644 --- a/ui/src/components/app-sidebar-session-row-render.ts +++ b/ui/src/components/app-sidebar-session-row-render.ts @@ -1,6 +1,7 @@ import { html, nothing, type TemplateResult } from "lit"; import { ifDefined } from "lit/directives/if-defined.js"; import { keyed } from "lit/directives/keyed.js"; +import { ref } from "lit/directives/ref.js"; import type { SessionObserverDigest } from "../../../packages/gateway-protocol/src/schema/sessions.js"; import type { NavigationRouteId } from "../app-navigation.ts"; import { withSidebarNavCollapseIntent } from "../app-session-route-paths.ts"; @@ -10,9 +11,14 @@ import { resolveControlUiAuthCandidates } from "../app/control-ui-auth.ts"; import { t } from "../i18n/index.ts"; import { sessionHasBoard } from "../lib/board/provider.ts"; import { formatDurationCompact } from "../lib/format.ts"; -import { startHoverMarquee, stopHoverMarquee } from "../lib/hover-marquee.ts"; +import { + restartHoverMarqueeIfHovered, + startHoverMarquee, + stopHoverMarquee, +} from "../lib/hover-marquee.ts"; import { handleContextMenuEvent } from "../lib/keyboard-shortcuts.ts"; import { projectPresencePayload } from "../lib/presence-users.ts"; +import type { CatalogSessionKey } from "../lib/sessions/catalog-key.ts"; import { writeSessionDragData } from "../lib/sessions/drag.ts"; import type { SidebarSessionsGrouping } from "../lib/sessions/grouping.ts"; import type { NewSessionTarget } from "../pages/new-session/location.ts"; @@ -138,6 +144,7 @@ export interface SessionListHost { y: number, trigger?: HTMLElement, ): void; + retargetCatalogMenuTrigger(key: CatalogSessionKey, element: Element | undefined): void; } export function visibleSessionChildren(params: { @@ -190,12 +197,18 @@ 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 - ? projectPresencePayload( - host.sessionData.presencePayload, - host.sessionDataContext?.gateway.snapshot.selfUser?.id, - host.sessionData.presenceInstanceId, - ).users.some((user) => user.id === ownerId && user.watchedSessions.includes(session.key)) + ? presenceProjection?.users.some( + (user) => user.id === ownerId && user.watchedSessions.includes(session.key), + ) : undefined; const gateway = host.sessionDataContext?.gateway; const channelAvatarAuth = { @@ -232,6 +245,20 @@ 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, @@ -241,7 +268,8 @@ export function renderRecentSession(params: { const pinLabel = `${t(session.pinned ? "sessionsView.unpinSession" : "sessionsView.pinSession")}: ${label}`; const menuTooltip = t("chat.sidebar.openSessionMenu"); const menuLabel = `${menuTooltip}: ${label}`; - const menuOpen = host.sidebarMenus.sessionMenu?.session.key === session.key; + const menuOpen = + host.sidebarMenus.sessionMenu?.session.key === session.key || display?.catalogMenuOpen === true; const rowClass = [ "sidebar-recent-session", "session-row-host", @@ -275,11 +303,59 @@ 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`${session.archived + ? html`${icons.archive}` + : nothing}${session.forkSource + ? html`${icons.gitFork}` + : nothing}${label}`; + const marqueeLabel = marqueeKey ? keyed(marqueeKey, marqueeLabelTemplate) : marqueeLabelTemplate; // Always reserve the lead so every title shares the section-label text line. const row = html` + @@ -520,6 +524,11 @@ 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"); diff --git a/ui/src/e2e/chat-flow.sidebar-presentation.e2e.test.ts b/ui/src/e2e/chat-flow.sidebar-presentation.e2e.test.ts index 1ef2ef7ba9df..44d9156aaf17 100644 --- a/ui/src/e2e/chat-flow.sidebar-presentation.e2e.test.ts +++ b/ui/src/e2e/chat-flow.sidebar-presentation.e2e.test.ts @@ -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 = "Short"; + firstSession.label = "Title fits until actions appear"; secondSession.label = "Review and repair the intentionally overlong sidebar session title before navigation ".repeat( 4, @@ -263,10 +263,51 @@ 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( @@ -279,7 +320,7 @@ suite.define(() => { "hover-marquee--scrolling", ); await recentRow.dispatchEvent("mouseenter"); - await page.clock.runFor(500); + await page.clock.runFor(520); await expect .poll(() => recentLabel.evaluate((label) => label.classList.value), { timeout: 1_500 }) .toContain("hover-marquee--scrolling"); @@ -437,7 +478,12 @@ 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); - expect(layout.badges.top).toBeGreaterThanOrEqual(layout.name.bottom - 1); + // 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.name.right).toBeGreaterThan(layout.badges.left); expect((layout.badges.top + layout.badges.bottom) / 2).toBeCloseTo( (layout.subtitle.top + layout.subtitle.bottom) / 2, @@ -497,7 +543,9 @@ suite.define(() => { .locator(".sidebar-recent-session__details-endcap") .evaluate((element) => getComputedStyle(element).opacity), ) - .toBe("0"); + // 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(() => busyRow @@ -587,4 +635,404 @@ 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) => ({ + 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 = {}) => + 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) => { + 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); + } + }); }); diff --git a/ui/src/e2e/codex-sessions.e2e.test.ts b/ui/src/e2e/codex-sessions.e2e.test.ts index 2d9f810f5ae7..6f4260021d53 100644 --- a/ui/src/e2e/codex-sessions.e2e.test.ts +++ b/ui/src/e2e/codex-sessions.e2e.test.ts @@ -5,6 +5,7 @@ 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", @@ -357,7 +358,7 @@ suite.define(() => { sessions: [ { threadId: "thread-local", - name: "Local planning session", + name: "Title fits until menu appears", cwd: "/Users/dev/openclaw", status: "idle", archived: false, @@ -464,6 +465,7 @@ 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 @@ -505,9 +507,10 @@ suite.define(() => { for (const metric of threadRowMetrics) { expect(metric.singleLine).toBe(true); } - const singleLineHeights = new Set(threadRowMetrics.map((metric) => metric.height)); - expect(singleLineHeights.size).toBe(1); - const [collapsedHeight] = [...singleLineHeights]; + 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) { @@ -518,6 +521,22 @@ suite.define(() => { paddingTop: "4px", }); } + const catalogRow = projectRows.first(); + await expectHoverMarqueeAfterActionsAppear(catalogRow); + const catalogActionReservation = await catalogRow.evaluate((row) => { + const text = row.querySelector(".sidebar-recent-session__text"); + const menu = row.querySelector("[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) => { @@ -627,7 +646,7 @@ suite.define(() => { await openclawProject.click(); await expect.poll(() => openclawProject.getAttribute("aria-expanded")).toBe("false"); - expect(await section.getByText("Local planning session", { exact: true }).count()).toBe(0); + expect(await localCatalogRow.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); @@ -643,7 +662,7 @@ suite.define(() => { await openclawProject.click(); await expect.poll(() => openclawProject.getAttribute("aria-expanded")).toBe("true"); - expect(await section.getByText("Local planning session", { exact: true }).count()).toBe(1); + expect(await localCatalogRow.count()).toBe(1); expect(await section.getByText("Worktree fix session", { exact: true }).count()).toBe(1); expect( await page.evaluate( diff --git a/ui/src/e2e/session-catalog-identity.e2e.test.ts b/ui/src/e2e/session-catalog-identity.e2e.test.ts new file mode 100644 index 000000000000..647d496c2d0c --- /dev/null +++ b/ui/src/e2e/session-catalog-identity.e2e.test.ts @@ -0,0 +1,176 @@ +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); + } + }, + ); +}); diff --git a/ui/src/e2e/session-management.test-support.ts b/ui/src/e2e/session-management.test-support.ts index befb28583076..dbc5ee2f014b 100644 --- a/ui/src/e2e/session-management.test-support.ts +++ b/ui/src/e2e/session-management.test-support.ts @@ -3,6 +3,7 @@ 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, @@ -45,6 +46,8 @@ export function sessionRow( pinned?: boolean; pinnedAt?: number; hasActiveRun?: boolean; + hasAutomation?: GatewaySessionRow["hasAutomation"]; + incognito?: GatewaySessionRow["incognito"]; unread?: boolean; status?: string; spawnedBy?: string; @@ -164,6 +167,37 @@ export function actionPointerEvents(button: Locator): Promise { 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 { + 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 diff --git a/ui/src/e2e/session-management.trailing-state.e2e.test.ts b/ui/src/e2e/session-management.trailing-state.e2e.test.ts index 1a378c8bea64..beb654c1ce3d 100644 --- a/ui/src/e2e/session-management.trailing-state.e2e.test.ts +++ b/ui/src/e2e/session-management.trailing-state.e2e.test.ts @@ -16,7 +16,6 @@ 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 }, @@ -41,30 +40,100 @@ 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, subtitleBounds, pinBounds, menuBounds] = await Promise.all([ - row.boundingBox(), - row.locator(".sidebar-recent-session__subtitle").boundingBox(), - pin.boundingBox(), - menu.boundingBox(), - ]); - if (!rowBounds || !subtitleBounds || !pinBounds || !menuBounds) { + 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 + ) { throw new Error("Expected visible two-line session action geometry"); } const rowCenter = rowBounds.y + rowBounds.height / 2; - 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); + 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 }); + + 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, + ); } finally { await context.close(); } }); - it("keeps action-only text widest at rest and swaps active state for actions", async () => { + it("keeps action-only text widest at rest and keeps active state lit", async () => { const context = await suite.browser.newContext({ locale: "en-US", serviceWorkers: "block", @@ -106,23 +175,41 @@ suite.define(() => { await actionOnlyRow.hover(); await expect.poll(() => actionOpacity(actionOnlyPin)).toBe("1"); - await expect - .poll(() => actionOnlyText.evaluate((element) => getComputedStyle(element).paddingRight)) - .toBe("52px"); - const hoveredTextBounds = await actionOnlyText.boundingBox(); + const [actionOnlyHoveredTextBounds, actionOnlyHoveredNameBounds, actionOnlyHoveredPinBounds] = + await Promise.all([ + actionOnlyText.boundingBox(), + actionOnlyRow.locator(".sidebar-recent-session__name").boundingBox(), + actionOnlyPin.boundingBox(), + ]); await page.mouse.move(0, 0); await actionOnlyPin.focus(); await expect.poll(() => actionOpacity(actionOnlyPin)).toBe("1"); - await expect - .poll(() => actionOnlyText.evaluate((element) => getComputedStyle(element).paddingRight)) - .toBe("52px"); - const focusedTextBounds = await actionOnlyText.boundingBox(); - if (!restingTextBounds || !hoveredTextBounds || !focusedTextBounds) { + 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 + ) { throw new Error("Expected visible action-only text geometry"); } - expect(hoveredTextBounds.width).toBeCloseTo(restingTextBounds.width, 1); - expect(focusedTextBounds.width).toBeCloseTo(restingTextBounds.width, 1); + 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, + ); const row = page.locator('[data-session-key="agent:main:hover-active"]'); await row.waitFor({ state: "visible", timeout: 10_000 }); @@ -133,16 +220,20 @@ suite.define(() => { await expect.poll(() => actionOpacity(state)).toBe("1"); await row.hover(); - await expect.poll(() => actionOpacity(state)).toBe("0"); + // 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] = await Promise.all([ + const [nameBounds, pinBounds, menuBounds, stateBounds] = await Promise.all([ row.locator(".sidebar-recent-session__name").boundingBox(), pin.boundingBox(), menu.boundingBox(), + state.boundingBox(), ]); - if (!nameBounds || !pinBounds || !menuBounds) { + if (!nameBounds || !pinBounds || !menuBounds || !stateBounds) { throw new Error("Expected visible hovered action geometry"); } expect(nameBounds.y + nameBounds.height / 2).toBeCloseTo( @@ -150,10 +241,13 @@ 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(); - await expect.poll(() => actionOpacity(state)).toBe("0"); + await expect.poll(() => actionOpacity(state)).toBe("1"); await expect.poll(() => actionOpacity(pin)).toBe("1"); await expect.poll(() => actionOpacity(menu)).toBe("1"); @@ -224,7 +318,10 @@ suite.define(() => { expect( Math.abs(forkBounds.y + forkBounds.height / 2 - (nameBounds.y + nameBounds.height / 2)), ).toBeLessThanOrEqual(2); - expect(nameBounds.y + nameBounds.height / 2).toBeLessThan(pinBounds.y + pinBounds.height / 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.x + nameBounds.width).toBeLessThanOrEqual(pinBounds.x + 1); expect(pinBounds.x + pinBounds.width).toBeLessThanOrEqual(menuBounds.x); } finally { @@ -232,6 +329,165 @@ 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("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(); + 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, @@ -244,10 +500,14 @@ suite.define(() => { methodResponses: { "sessions.list": sessionsListResponse([ sessionRow("agent:main:main", "Main", Date.now()), - sessionRow("agent:main:touch-active", "Touch active", Date.now() - 1, { - hasActiveRun: true, - status: "running", - }), + 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), ]), }, sessionKey: "agent:main:main", @@ -256,7 +516,9 @@ 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" }); @@ -265,17 +527,112 @@ suite.define(() => { await expect.poll(() => pin.isVisible()).toBe(true); await expect.poll(() => menu.isVisible()).toBe(true); - const [stateBounds, pinBounds] = await Promise.all([state.boundingBox(), pin.boundingBox()]); - if (!stateBounds || !pinBounds) { + const [rowBounds, stateBounds, pinBounds, menuBounds] = await Promise.all([ + row.boundingBox(), + state.boundingBox(), + pin.boundingBox(), + menu.boundingBox(), + ]); + if (!rowBounds || !stateBounds || !pinBounds || !menuBounds) { 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("does not widen desktop session text when hover actions replace trailing state", async () => { + 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(".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 () => { const context = await suite.browser.newContext({ locale: "en-US", serviceWorkers: "block", @@ -364,6 +721,20 @@ 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( ".sidebar-recent-session__details-endcap", @@ -402,7 +773,6 @@ suite.define(() => { expect(atom.right).toBeLessThanOrEqual(stateLayout.endcapRight); } const link = row.locator(".sidebar-recent-session__link"); - const rowText = row.locator(".sidebar-recent-session__text"); const pin = row.getByRole("button", { name: "Pin session" }); const menu = row.getByRole("button", { name: "Open session menu" }); await expect @@ -431,34 +801,39 @@ suite.define(() => { restingStateBounds.y + restingStateBounds.height / 2, ); await row.hover(); - await expect.poll(() => actionOpacity(state)).toBe("0"); + await expect.poll(() => actionOpacity(state)).toBe("1"); await expect.poll(() => actionOpacity(pin)).toBe("1"); await expect.poll(() => actionOpacity(menu)).toBe("1"); - await expect - .poll(() => rowText.evaluate((element) => getComputedStyle(element).paddingRight)) - .toBe("52px"); - const [textBounds, nameBounds, pinBounds, menuBounds] = await Promise.all([ + const [textBounds, nameBounds, pinBounds, pinGlyphBounds, 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 || !menuBounds) { + if (!textBounds || !nameBounds || !pinBounds || !pinGlyphBounds || !menuBounds) { throw new Error("Expected visible combined session action geometry"); } expect(textBounds.width).toBeCloseTo(restingTextBounds.width, 1); - expect(nameBounds.y + nameBounds.height / 2).toBeLessThan(pinBounds.y + pinBounds.height / 2); + // 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.x + nameBounds.width).toBeLessThanOrEqual(pinBounds.x + 1); expect(pinBounds.x + pinBounds.width).toBeLessThanOrEqual(menuBounds.x); await page.mouse.move(0, 0); await pin.focus(); - await expect.poll(() => actionOpacity(state)).toBe("0"); + await expect.poll(() => actionOpacity(state)).toBe("1"); await expect.poll(() => actionOpacity(pin)).toBe("1"); await expect.poll(() => actionOpacity(menu)).toBe("1"); - await expect - .poll(() => rowText.evaluate((element) => getComputedStyle(element).paddingRight)) - .toBe("52px"); const [focusedTextBounds, focusedNameBounds, focusedPinBounds, focusedMenuBounds] = await Promise.all([ @@ -471,9 +846,13 @@ suite.define(() => { throw new Error("Expected visible focused session action geometry"); } expect(focusedTextBounds.width).toBeCloseTo(restingTextBounds.width, 1); - expect(focusedNameBounds.y + focusedNameBounds.height / 2).toBeLessThan( - focusedPinBounds.y + focusedPinBounds.height / 2, - ); + expect( + Math.abs( + focusedNameBounds.y + + focusedNameBounds.height / 2 - + (focusedPinBounds.y + focusedPinBounds.height / 2), + ), + ).toBeLessThanOrEqual(1); expect(focusedNameBounds.x + focusedNameBounds.width).toBeLessThanOrEqual( focusedPinBounds.x + 1, ); diff --git a/ui/src/lib/hover-marquee.test.ts b/ui/src/lib/hover-marquee.test.ts index 237ba54deedd..dc49692f9f7b 100644 --- a/ui/src/lib/hover-marquee.test.ts +++ b/ui/src/lib/hover-marquee.test.ts @@ -1,22 +1,40 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { startHoverMarquee, stopHoverMarquee } from "./hover-marquee.ts"; +let pendingFrame: FrameRequestCallback | undefined; + +function runPendingFrame(): void { + const callback = pendingFrame; + pendingFrame = undefined; + callback?.(0); +} + 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"; - row.append(label); + viewport.append(label); + row.append(viewport); document.body.append(row); Object.defineProperty(label, "clientWidth", { value: params.labelWidth }); Object.defineProperty(label, "scrollWidth", { value: params.textWidth }); - return { row, label }; + return { row, viewport, label }; } describe("hover marquee", () => { - beforeEach(() => vi.useFakeTimers()); + beforeEach(() => { + vi.useFakeTimers(); + pendingFrame = undefined; + vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => { + pendingFrame = callback; + return 1; + }); + }); afterEach(() => { + vi.restoreAllMocks(); vi.useRealTimers(); document.body.replaceChildren(); }); @@ -24,6 +42,8 @@ describe("hover marquee", () => { 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(); expect(label.style.getPropertyValue("--hover-marquee-shift")).toBe("-140px"); expect(label.style.getPropertyValue("--hover-marquee-duration")).toBe("1750ms"); vi.advanceTimersByTime(499); @@ -37,22 +57,56 @@ describe("hover marquee", () => { it("cancels the delayed scroll when hover ends early", () => { const { row, label } = buildRow({ textWidth: 320, labelWidth: 180 }); startHoverMarquee(row); + runPendingFrame(); vi.advanceTimersByTime(250); stopHoverMarquee(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(); 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(); expect(label.classList.contains("hover-marquee--scrolling")).toBe(false); expect(label.style.getPropertyValue("--hover-marquee-shift")).toBe(""); }); diff --git a/ui/src/lib/hover-marquee.ts b/ui/src/lib/hover-marquee.ts index 8d3599342ca0..7d803984aef0 100644 --- a/ui/src/lib/hover-marquee.ts +++ b/ui/src/lib/hover-marquee.ts @@ -6,7 +6,9 @@ const MARQUEE_SPEED_PX_PER_SEC = 80; const MARQUEE_MIN_DURATION_MS = 300; const MARQUEE_HOVER_DELAY_MS = 500; -const pendingMarquees = new WeakMap(); +type PendingMarquee = { frame: number; timer?: number }; + +const pendingMarquees = new WeakMap(); function findMarqueeLabel(host: HTMLElement): HTMLElement | null { return host.classList.contains("hover-marquee") @@ -14,43 +16,76 @@ function findMarqueeLabel(host: HTMLElement): HTMLElement | null { : host.querySelector(".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.clearTimeout(pending); + window.cancelAnimationFrame(pending.frame); + if (pending.timer !== undefined) { + window.clearTimeout(pending.timer); + } pendingMarquees.delete(label); } export function startHoverMarquee(host: HTMLElement): void { const label = findMarqueeLabel(host); - if (!label || label.classList.contains("hover-marquee--scrolling")) { + if ( + !label || + label.classList.contains("hover-marquee--scrolling") || + pendingMarquees.has(label) + ) { 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) { - 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), - ); + // 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); } export function stopHoverMarquee(host: HTMLElement): void { @@ -61,3 +96,29 @@ export function stopHoverMarquee(host: HTMLElement): void { 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(".session-row-host") + : undefined; + if (host?.matches(":hover")) { + startHoverMarquee(host); + } + }); +} + +export function startHoverMarqueeFromEvent(event: Event): void { + if (event.currentTarget instanceof HTMLElement) { + startHoverMarquee(event.currentTarget); + } +} + +export function stopHoverMarqueeFromEvent(event: Event): void { + if (event.currentTarget instanceof HTMLElement) { + stopHoverMarquee(event.currentTarget); + } +} diff --git a/ui/src/styles/components.css b/ui/src/styles/components.css index a823b23c8781..52d413409300 100644 --- a/ui/src/styles/components.css +++ b/ui/src/styles/components.css @@ -5815,9 +5815,26 @@ td.data-table-key-col { 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-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); + position: relative; } +.session-row-host[data-session-row-action-count="1"] { + --row-actions-reserve: calc(var(--row-action-size) + 3px); +} + .session-row-host--draft { --draft-row-opacity: 1; opacity: var(--draft-row-opacity); @@ -5887,14 +5904,19 @@ td.data-table-key-col { pointer-events: none; } +/* Sized to its glyphs. The old 24px box matched the action button this used to + trade places with, which padded a 7px unread dot with 8.5px a side and broke + the endcap's even 6px rhythm. Nothing trades now, so it sizes to content. */ .session-row-state { display: inline-flex; align-items: center; justify-content: center; - gap: 6px; - min-width: 24px; - min-height: 24px; - transition: opacity var(--duration-fast) ease; + gap: var(--row-glyph-gap, 6px); + /* The ring and the unread dot are bare shapes, not icons in a box, so + right-flush they hung 2-4px past the icon column above them. One glyph-wide + floor plus the centring above puts whatever the row is showing on the same + axis as the icons. */ + min-width: 14px; pointer-events: none; } @@ -5918,7 +5940,7 @@ td.data-table-key-col { .session-row-badges { display: inline-flex; align-items: center; - gap: 6px; + gap: var(--row-glyph-gap, 6px); flex: 0 0 auto; } @@ -5928,7 +5950,8 @@ td.data-table-key-col { color: var(--muted); } -.session-row-badge--attention { +.session-row-badge--attention, +.session-row-badge--approval { gap: 2px; color: var(--warn); font-size: 10px; @@ -5944,13 +5967,8 @@ td.data-table-key-col { color: var(--accent); } -.session-row-badge--approval { - display: inline-flex; - align-items: center; - color: var(--warn); -} - -.session-row-badge--cloud[data-placement-state="failed"] { +.session-row-badge--cloud[data-placement-state="failed"], +.session-row-badge--cloud[data-disk-space-status="critical"] { color: var(--danger); } @@ -5965,18 +5983,11 @@ td.data-table-key-col { color: var(--accent); } -.session-row-badge--cloud[data-workspace-conflicts] { - color: var(--warn); -} - +.session-row-badge--cloud[data-workspace-conflicts], .session-row-badge--cloud[data-disk-space-status="warning"] { color: var(--warn); } -.session-row-badge--cloud[data-disk-space-status="critical"] { - color: var(--danger); -} - .session-row-badge svg { width: 12px; height: 12px; @@ -5989,16 +6000,14 @@ 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: 24px; - height: 24px; + width: var(--row-action-size); + height: var(--row-action-size); padding: 0; border: none; border-radius: var(--radius-sm); @@ -6006,13 +6015,9 @@ 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-action svg { +.session-row-host svg { width: 14px; height: 14px; stroke: currentColor; @@ -6045,13 +6050,20 @@ td.data-table-key-col { pointer-events: auto; } -.session-row-host:is(:hover, :focus-within) :is(.session-row-trail, .session-row-state) { - opacity: 0; -} +/* 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; + } + .sidebar-recent-session .session-action { opacity: 1; pointer-events: auto; @@ -6060,22 +6072,6 @@ td.data-table-key-col { .sidebar-recent-session .session-action:disabled { opacity: 0.35; } - - .sidebar-recent-session .session-row-trail { - opacity: 0; - } - - .sidebar-recent-session .session-row-aside:has(> .session-row-state) { - column-gap: 1px; - } - - .sidebar-recent-session .session-row-state { - grid-area: 1 / 1; - } - - .sidebar-recent-session .session-row-actions { - grid-area: 1 / 2; - } } .session-row-host--pinned .session-action--pin { @@ -6085,8 +6081,8 @@ td.data-table-key-col { .session-run-spinner { box-sizing: border-box; display: inline-block; - width: 12px; - height: 12px; + width: 11px; + height: 11px; flex: 0 0 auto; border: 1.5px solid color-mix(in srgb, var(--run) 28%, transparent); border-top-color: var(--run); diff --git a/ui/src/styles/layout.css b/ui/src/styles/layout.css index f8ef60628149..42abae4f191e 100644 --- a/ui/src/styles/layout.css +++ b/ui/src/styles/layout.css @@ -1851,6 +1851,9 @@ 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; align-items: center; @@ -2085,6 +2088,7 @@ openclaw-settings-save-indicator:empty { .sidebar-recent-session__name { display: block; width: 100%; + line-height: var(--sidebar-title-line-height); flex: 0 0 auto; min-width: 0; overflow: hidden; @@ -2153,8 +2157,9 @@ openclaw-settings-save-indicator:empty { padding-right: 2px; } -/* Actions overlay the row center. The buttons re-enable their own - pointer-events on hover/focus. */ +/* 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; @@ -2162,6 +2167,18 @@ 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; +} + +.sidebar-recent-session:not(.sidebar-recent-session--child, .sidebar-recent-session--single-line) + > .sidebar-recent-session__aside { + inset: calc(1px + max(24px, var(--sidebar-title-line-height)) / 2) 2px auto auto; +} + .sidebar-recent-session--child > .sidebar-recent-session__aside { position: relative; z-index: 1; @@ -2179,23 +2196,21 @@ openclaw-settings-save-indicator:empty { right: calc(var(--sidebar-child-session-toggle-width) + 4px); } -/* Centered actions overlap both lines, so the full text column yields their width. */ -.sidebar-recent-session:not(.sidebar-recent-session--child):is(:hover, :focus-within) +/* 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); +} + +/* 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: 52px; -} - -.sidebar-recent-session:not(.sidebar-recent-session--child):is(:hover, :focus-within) - .sidebar-recent-session__details-endcap { - opacity: 0; - pointer-events: none; -} - -/* Touch keeps the actions permanently visible beside the persistent endcap. */ -@media (hover: none), (pointer: coarse) { - .sidebar-recent-session:not(.sidebar-recent-session--child) .sidebar-recent-session__text { - padding-right: 52px; - } + padding-right: var(--row-actions-reserve); } .nav-collapse-toggle__icon { @@ -3026,6 +3041,37 @@ wa-dropdown-item.session-menu__item::part(submenu-icon) { padding: 3px 0; } +@media (hover: none), (pointer: coarse) { + .sidebar-recent-session, + .sidebar-recent-session__link, + .sidebar-zone-entry :is(.sidebar-recent-session, .sidebar-recent-session__link) { + min-height: 44px; + } + + .sidebar-recent-session { + --sidebar-child-session-toggle-width: max(44px, calc(36px * var(--control-ui-text-scale))); + } + + .sidebar-child-session-toggle { + height: 44px; + } + + .sidebar-recent-session:not(.sidebar-recent-session--child, .sidebar-recent-session--single-line) + > .sidebar-recent-session__aside { + 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); + } + + .sidebar-recent-session:not(.sidebar-recent-session--child) .sidebar-recent-session__title-row { + padding-right: 0; + } +} + .sidebar-zone-entry .sidebar-recent-session:hover { color: var(--text); background: color-mix(in srgb, var(--bg-hover) 84%, transparent); @@ -4387,7 +4433,28 @@ html:not(.openclaw-native-macos):not(.openclaw-native-nav):not(.openclaw-native- flex: 1 1 auto; flex-direction: column; min-width: 0; - transition: padding-right var(--duration-fast) ease; +} + +/* 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; } .sidebar-recent-session__details { @@ -4430,17 +4497,20 @@ 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: 6px; + gap: var(--row-glyph-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; - transition: opacity var(--duration-fast) ease; } .sidebar-recent-session__details-endcap .session-row-state { @@ -4457,10 +4527,7 @@ html:not(.openclaw-native-macos):not(.openclaw-native-nav):not(.openclaw-native- } .sidebar-session-attention__icon--question, -.sidebar-session-attention__icon--approval { - color: var(--warn); -} - +.sidebar-session-attention__icon--approval, .sidebar-session-attention__icon--agent { color: var(--warn); } diff --git a/ui/src/test-helpers/app-sidebar-cases/catalog-row-keying.ts b/ui/src/test-helpers/app-sidebar-cases/catalog-row-keying.ts new file mode 100644 index 000000000000..a2ad77b30000 --- /dev/null +++ b/ui/src/test-helpers/app-sidebar-cases/catalog-row-keying.ts @@ -0,0 +1,164 @@ +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( + '[data-session-section="catalog:codex"] [data-session-key$=":thread-first"]', + ); + const firstLabel = firstRow?.querySelector(".hover-marquee"); + const firstMenu = firstRow?.querySelector("[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( + '[data-session-section="catalog:codex"] [data-session-key$=":thread-first"]', + ); + const updatedLabel = updatedRow?.querySelector(".hover-marquee"); + const updatedMenu = updatedRow?.querySelector("[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( + '[data-session-section="catalog:codex"] .sidebar-recent-session', + ); + const replacementLabel = replacementRow?.querySelector(".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("[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( + `[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(`[data-session-key="${adoptedKey}"]`); + const label = row?.querySelector(".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(`[data-session-key="${adoptedKey}"]`); + const updatedLabel = updatedRow?.querySelector(".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( + `[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( + '[data-session-key$=":thread-released"] [data-catalog-session-menu]', + ); + expect(document.activeElement).toBe(nativeMenu); + }); +}); diff --git a/ui/src/test-helpers/app-sidebar.ts b/ui/src/test-helpers/app-sidebar.ts index 24c7b8f545ff..5b25ff62eec0 100644 --- a/ui/src/test-helpers/app-sidebar.ts +++ b/ui/src/test-helpers/app-sidebar.ts @@ -569,7 +569,7 @@ export const manyAgents = (count: number) => }) as AgentsListResult; export const catalogPage = ( - sessions: Array<{ threadId: string; name: string }>, + sessions: Array<{ threadId: string; name: string; sessionKey?: string }>, nextCursor?: string, catalogId = "codex", ): SessionsCatalogListResult => ({