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