diff --git a/ui/src/components/app-sidebar-session-catalog-render.ts b/ui/src/components/app-sidebar-session-catalog-render.ts index 130b5145ae89..7c1dc07f310b 100644 --- a/ui/src/components/app-sidebar-session-catalog-render.ts +++ b/ui/src/components/app-sidebar-session-catalog-render.ts @@ -485,22 +485,28 @@ function renderCatalogSessionRow( ${label} + + + ${renderSessionRowBadges({ + hasAutomation: false, + pullRequest: session.pullRequest, + })} + ${running + ? html` + ${renderSessionRunSpinner(false)} + ` + : nothing} + + - ${renderSessionRowBadges({ - hasAutomation: false, - pullRequest: session.pullRequest, - })} - ${running - ? html`${renderSessionRunSpinner(false)}` - : nothing} ` : nothing} - - ${trailingIndicator === nothing - ? nothing - : html`${trailingIndicator}`} - ${hasTrail - ? html`${session.runtimeMs != null - ? session.hasActiveRun - ? html`` - : (formatDurationCompact(session.runtimeMs) ?? "0ms") - : html``}` - : nothing} - ${session.isChild - ? nothing - : html` + ${session.isChild + ? nothing + : html` + - `} - + + `} `; // Marquee state mutates the row DOM; keying prevents cross-session reuse. 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 10c01616c064..d35f06bb42dd 100644 --- a/ui/src/e2e/chat-flow.sidebar-presentation.e2e.test.ts +++ b/ui/src/e2e/chat-flow.sidebar-presentation.e2e.test.ts @@ -17,6 +17,12 @@ const terminalMetadataProofDir = path.join( "control-ui-e2e", "remote-session-sidebar-metadata", ); +const sessionSecondRowProofDir = path.join( + process.cwd(), + ".artifacts", + "control-ui-e2e", + "session-status-second-row-implementation", +); suite.define(() => { it("replaces an intermediate running subtitle with the durable final reply", async () => { @@ -213,6 +219,122 @@ suite.define(() => { } }); + it("keeps session titles on the first line and status on a fixed second line", async () => { + if (captureUiProofEnabled) { + await mkdir(sessionSecondRowProofDir, { recursive: true }); + } + const context = await suite.newBrowserContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + ...(captureUiProofEnabled + ? { recordVideo: { dir: sessionSecondRowProofDir, size: { height: 900, width: 1280 } } } + : {}), + }); + const page = await context.newPage(); + const busyKey = "agent:main:busy-session"; + const plainKey = "agent:main:plain-session"; + await installMockGateway(page, { + methodResponses: { + "sessions.list": chatSessionListResponse([ + { + key: busyKey, + kind: "direct", + label: "Terminal tab bar redesign proposal", + updatedAt: 2, + lastMessagePreview: "Implemented and committed as 094ab2", + incognito: true, + hasAutomation: true, + status: "done", + unread: true, + }, + { + key: plainKey, + kind: "direct", + label: "A session without secondary metadata", + updatedAt: 1, + }, + ]), + }, + sessionKey: busyKey, + }); + + try { + await page.goto(`${suite.server.baseUrl}chat`); + 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(); + if (captureUiProofEnabled) { + await page.screenshot({ + fullPage: true, + path: path.join(sessionSecondRowProofDir, "01-second-row-endcap.png"), + }); + } + + const layout = await busyRow.evaluate((row) => { + const rect = (selector: string) => { + const element = row.querySelector(selector); + if (!element) { + throw new Error(`Missing session row fixture ${selector}`); + } + const box = element.getBoundingClientRect(); + return { + bottom: box.bottom, + height: box.height, + left: box.left, + right: box.right, + top: box.top, + }; + }; + return { + badges: rect(".session-row-badges"), + busyHeight: row.getBoundingClientRect().height, + name: rect(".sidebar-recent-session__name"), + state: rect(".session-row-state"), + subtitle: rect(".sidebar-recent-session__subtitle"), + }; + }); + const plainHeight = await plainRow.evaluate((row) => row.getBoundingClientRect().height); + + expect(layout.busyHeight).toBeCloseTo(plainHeight, 1); + 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, + 1, + ); + expect((layout.state.top + layout.state.bottom) / 2).toBeCloseTo( + (layout.subtitle.top + layout.subtitle.bottom) / 2, + 1, + ); + + await busyRow.hover(); + await expect + .poll(() => + busyRow + .locator(".sidebar-recent-session__details-endcap") + .evaluate((element) => getComputedStyle(element).opacity), + ) + .toBe("0"); + await expect + .poll(() => + busyRow + .locator("[data-session-menu]") + .evaluate((element) => getComputedStyle(element).opacity), + ) + .toBe("1"); + if (captureUiProofEnabled) { + await page.screenshot({ + fullPage: true, + path: path.join(sessionSecondRowProofDir, "02-hover-actions.png"), + }); + } + await plainRow.waitFor(); + } finally { + await suite.closeBrowserContext(context); + } + }); + it("keeps the authenticated assistant avatar stable across same-agent switches", async () => { const context = await suite.newBrowserContext({ locale: "en-US", diff --git a/ui/src/e2e/codex-sessions.e2e.test.ts b/ui/src/e2e/codex-sessions.e2e.test.ts index f893d81fe4cd..a84cd9e8af04 100644 --- a/ui/src/e2e/codex-sessions.e2e.test.ts +++ b/ui/src/e2e/codex-sessions.e2e.test.ts @@ -495,36 +495,17 @@ suite.define(() => { }; }), ); - expect(threadRowMetrics).toEqual([ - { - height: 30, + expect(threadRowMetrics).toHaveLength(4); + expect(new Set(threadRowMetrics.map((metric) => metric.height)).size).toBe(1); + expect(threadRowMetrics[0]?.height).toBeGreaterThan(30); + for (const metric of threadRowMetrics) { + expect(metric).toMatchObject({ minHeight: "30px", nameFontSize: "13px", paddingBottom: "3px", paddingTop: "3px", - }, - { - height: 30, - minHeight: "30px", - nameFontSize: "13px", - paddingBottom: "3px", - paddingTop: "3px", - }, - { - height: 30, - minHeight: "30px", - nameFontSize: "13px", - paddingBottom: "3px", - paddingTop: "3px", - }, - { - height: 30, - minHeight: "30px", - nameFontSize: "13px", - paddingBottom: "3px", - paddingTop: "3px", - }, - ]); + }); + } const projectLabelTone = await openclawProject .locator(".sidebar-session-catalog-project__label") .evaluate((label) => { diff --git a/ui/src/e2e/session-management.groups.e2e.test.ts b/ui/src/e2e/session-management.groups.e2e.test.ts index 66c1312291d1..ac4214cf2fa2 100644 --- a/ui/src/e2e/session-management.groups.e2e.test.ts +++ b/ui/src/e2e/session-management.groups.e2e.test.ts @@ -855,9 +855,10 @@ suite.define(() => { await captureUiProof(page, "sidebar-session-dropped-into-group.png"); const ungrouped = page.locator('[data-session-section="ungrouped"]'); + const ungroupedHead = ungrouped.locator(":scope > .sidebar-recent-sessions__head"); await gamma .locator('.sidebar-recent-session[data-session-key="agent:main:session-11"]') - .dragTo(ungrouped); + .dragTo(ungroupedHead, { targetPosition: { x: 4, y: 2 } }); const ungroupedPatch = await waitForPatch( gateway, (params) => params.key === "agent:main:session-11" && params.category === null, 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 d99bfbf19e73..a0a19c175ede 100644 --- a/ui/src/e2e/session-management.trailing-state.e2e.test.ts +++ b/ui/src/e2e/session-management.trailing-state.e2e.test.ts @@ -44,6 +44,7 @@ suite.define(() => { await actionOnlyRow.waitFor({ state: "visible", timeout: 10_000 }); const actionOnlyText = actionOnlyRow.locator(".sidebar-recent-session__text"); const actionOnlyLink = actionOnlyRow.locator(".sidebar-recent-session__link"); + const actionOnlyDetails = actionOnlyRow.locator(".sidebar-recent-session__details"); const actionOnlyPin = actionOnlyRow.getByRole("button", { name: "Pin session" }); await expect .poll(() => actionOnlyLink.evaluate((element) => getComputedStyle(element).paddingRight)) @@ -53,7 +54,7 @@ suite.define(() => { await actionOnlyRow.hover(); await expect.poll(() => actionOpacity(actionOnlyPin)).toBe("1"); await expect - .poll(() => actionOnlyLink.evaluate((element) => getComputedStyle(element).paddingRight)) + .poll(() => actionOnlyDetails.evaluate((element) => getComputedStyle(element).paddingRight)) .toBe("52px"); const hoveredTextBounds = await actionOnlyText.boundingBox(); @@ -61,14 +62,14 @@ suite.define(() => { await actionOnlyPin.focus(); await expect.poll(() => actionOpacity(actionOnlyPin)).toBe("1"); await expect - .poll(() => actionOnlyLink.evaluate((element) => getComputedStyle(element).paddingRight)) + .poll(() => actionOnlyDetails.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(restingTextBounds.width).toBeGreaterThanOrEqual(hoveredTextBounds.width); - expect(restingTextBounds.width).toBeGreaterThanOrEqual(focusedTextBounds.width); + 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 }); @@ -91,7 +92,7 @@ suite.define(() => { if (!nameBounds || !pinBounds || !menuBounds) { throw new Error("Expected visible hovered action geometry"); } - expect(nameBounds.x + nameBounds.width).toBeLessThanOrEqual(pinBounds.x); + expect(nameBounds.y + nameBounds.height / 2).toBeLessThan(pinBounds.y + pinBounds.height / 2); expect(pinBounds.x + pinBounds.width).toBeLessThanOrEqual(menuBounds.x); await page.mouse.move(0, 0); @@ -108,7 +109,9 @@ suite.define(() => { if (!focusedNameBounds || !focusedPinBounds || !focusedMenuBounds) { throw new Error("Expected visible focused action geometry"); } - expect(focusedNameBounds.x + focusedNameBounds.width).toBeLessThanOrEqual(focusedPinBounds.x); + expect(focusedNameBounds.y + focusedNameBounds.height / 2).toBeLessThan( + focusedPinBounds.y + focusedPinBounds.height / 2, + ); expect(focusedPinBounds.x + focusedPinBounds.width).toBeLessThanOrEqual(focusedMenuBounds.x); } finally { await context.close(); @@ -161,7 +164,9 @@ suite.define(() => { if (!nameBounds || !stateBounds || !pinBounds || !menuBounds) { throw new Error("Expected visible non-running touch state geometry"); } - expect(nameBounds.x + nameBounds.width).toBeLessThanOrEqual(stateBounds.x); + expect(nameBounds.y + nameBounds.height / 2).toBeLessThan( + stateBounds.y + stateBounds.height / 2, + ); expect(stateBounds.x + stateBounds.width).toBeLessThanOrEqual(pinBounds.x); expect(pinBounds.x + pinBounds.width).toBeLessThanOrEqual(menuBounds.x); } finally { @@ -297,11 +302,12 @@ suite.define(() => { await expect.poll(() => state.locator(".session-run-spinner").isVisible()).toBe(true); await expect.poll(() => state.locator(".session-unread-dot").isVisible()).toBe(true); const link = row.locator(".sidebar-recent-session__link"); + const details = row.locator(".sidebar-recent-session__details"); const pin = row.getByRole("button", { name: "Pin session" }); const menu = row.getByRole("button", { name: "Open session menu" }); await expect .poll(() => link.evaluate((element) => getComputedStyle(element).paddingRight)) - .toBe("68px"); + .toBe("4px"); const [restingTextBounds, restingStateBounds, restingPinBounds, restingMenuBounds] = await Promise.all([ @@ -314,30 +320,35 @@ suite.define(() => { throw new Error("Expected visible resting session state geometry"); } const actionSurfaceWidth = restingMenuBounds.x + restingMenuBounds.width - restingPinBounds.x; - expect(restingTextBounds.x + restingTextBounds.width).toBeLessThanOrEqual( - restingStateBounds.x, - ); expect(restingTextBounds.x + restingTextBounds.width).toBeGreaterThan( restingStateBounds.x - actionSurfaceWidth, ); + const restingNameBounds = await row.locator(".sidebar-recent-session__name").boundingBox(); + if (!restingNameBounds) { + throw new Error("Expected visible resting session title geometry"); + } + expect(restingNameBounds.y + restingNameBounds.height / 2).toBeLessThan( + restingStateBounds.y + restingStateBounds.height / 2, + ); await row.hover(); await expect.poll(() => actionOpacity(state)).toBe("0"); await expect.poll(() => actionOpacity(pin)).toBe("1"); await expect.poll(() => actionOpacity(menu)).toBe("1"); await expect - .poll(() => link.evaluate((element) => getComputedStyle(element).paddingRight)) - .toBe("68px"); + .poll(() => details.evaluate((element) => getComputedStyle(element).paddingRight)) + .toBe("52px"); - const [textBounds, pinBounds, 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(), menu.boundingBox(), ]); - if (!textBounds || !pinBounds || !menuBounds) { + if (!textBounds || !nameBounds || !pinBounds || !menuBounds) { throw new Error("Expected visible combined session action geometry"); } - expect(restingTextBounds.width).toBeGreaterThanOrEqual(textBounds.width); - expect(textBounds.x + textBounds.width).toBeLessThanOrEqual(pinBounds.x); + expect(textBounds.width).toBeCloseTo(restingTextBounds.width, 1); + expect(nameBounds.y + nameBounds.height / 2).toBeLessThan(pinBounds.y + pinBounds.height / 2); expect(pinBounds.x + pinBounds.width).toBeLessThanOrEqual(menuBounds.x); await page.mouse.move(0, 0); await pin.focus(); @@ -345,19 +356,23 @@ suite.define(() => { await expect.poll(() => actionOpacity(pin)).toBe("1"); await expect.poll(() => actionOpacity(menu)).toBe("1"); await expect - .poll(() => link.evaluate((element) => getComputedStyle(element).paddingRight)) - .toBe("68px"); + .poll(() => details.evaluate((element) => getComputedStyle(element).paddingRight)) + .toBe("52px"); - const [focusedTextBounds, focusedPinBounds, focusedMenuBounds] = await Promise.all([ - row.locator(".sidebar-recent-session__text").boundingBox(), - pin.boundingBox(), - menu.boundingBox(), - ]); - if (!focusedTextBounds || !focusedPinBounds || !focusedMenuBounds) { + const [focusedTextBounds, focusedNameBounds, focusedPinBounds, focusedMenuBounds] = + await Promise.all([ + row.locator(".sidebar-recent-session__text").boundingBox(), + row.locator(".sidebar-recent-session__name").boundingBox(), + pin.boundingBox(), + menu.boundingBox(), + ]); + if (!focusedTextBounds || !focusedNameBounds || !focusedPinBounds || !focusedMenuBounds) { throw new Error("Expected visible focused session action geometry"); } - expect(restingTextBounds.width).toBeGreaterThanOrEqual(focusedTextBounds.width); - expect(focusedTextBounds.x + focusedTextBounds.width).toBeLessThanOrEqual(focusedPinBounds.x); + expect(focusedTextBounds.width).toBeCloseTo(restingTextBounds.width, 1); + expect(focusedNameBounds.y + focusedNameBounds.height / 2).toBeLessThan( + focusedPinBounds.y + focusedPinBounds.height / 2, + ); expect(focusedPinBounds.x + focusedPinBounds.width).toBeLessThanOrEqual(focusedMenuBounds.x); } finally { await context.close(); diff --git a/ui/src/styles/components.css b/ui/src/styles/components.css index d86ece709c3e..6f0319a7741b 100644 --- a/ui/src/styles/components.css +++ b/ui/src/styles/components.css @@ -5687,9 +5687,7 @@ td.data-table-key-col { } /* Attribute badges (attached automation, approval, cloud placement): - muted metadata that sits after the title inside the row link, outside the - trail/action overlap cell, so touch devices (which hide the trail) and - hovered rows keep them. */ + muted metadata grouped in the row's second-line endcap. */ .session-row-badges { display: inline-flex; align-items: center; diff --git a/ui/src/styles/layout.css b/ui/src/styles/layout.css index 08b39962b0f2..b76b9e44afed 100644 --- a/ui/src/styles/layout.css +++ b/ui/src/styles/layout.css @@ -2254,7 +2254,9 @@ body.update-dialog-open .sidebar-update-card__status { } .sidebar-recent-session__name { - flex: 1 1 auto; + display: block; + width: 100%; + flex: 0 0 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; @@ -2322,15 +2324,12 @@ body.update-dialog-open .sidebar-update-card__status { padding-right: 2px; } -/* The trailing aside overlays the row so action-only rows retain the full - resting link width. It must not intercept clicks on the link beneath it — - the buttons re-enable their own pointer-events on hover/focus. Child rows - keep the in-flow aside because their runtime trail is resting content. */ +/* Actions overlay only the second line, preserving the title's full width. + 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; - top: 50%; + bottom: 0; right: 2px; - transform: translateY(-50%); pointer-events: none; } @@ -2342,42 +2341,22 @@ body.update-dialog-open .sidebar-update-card__status { right: calc(var(--sidebar-child-session-toggle-width) + 4px); } -/* State-bearing rows reserve the larger state/action maximum throughout the - swap, while action-only rows reserve controls only when they appear. */ -.sidebar-recent-session:not(.sidebar-recent-session--child):where( - :has(> .sidebar-recent-session__aside > .session-row-state) - ) - > .sidebar-recent-session__link { - padding-right: 68px; -} - -.sidebar-recent-session:not(.sidebar-recent-session--child):not( - :has(> .sidebar-recent-session__aside > .session-row-state) - ):is(:hover, :focus-within) - > .sidebar-recent-session__link { +/* Hover actions replace the second-row endcap without shortening the title. */ +.sidebar-recent-session:not(.sidebar-recent-session--child):is(:hover, :focus-within) + .sidebar-recent-session__details { padding-right: 52px; } -/* While the actions are surfaced, the link content fades out beneath them. - A mask (not a background scrim) keeps the fade correct over rest, hover, - active, and selected row backgrounds alike. */ -.sidebar-recent-session:not(.sidebar-recent-session--child):hover > .sidebar-recent-session__link, -.sidebar-recent-session:not(.sidebar-recent-session--child):focus-within - > .sidebar-recent-session__link { - mask-image: linear-gradient(to right, black calc(100% - 80px), transparent calc(100% - 52px)); +.sidebar-recent-session:not(.sidebar-recent-session--child):is(:hover, :focus-within) + .sidebar-recent-session__details-endcap { + opacity: 0; + pointer-events: none; } -/* Touch keeps the actions permanently visible, so the fade is permanent too. */ +/* Touch keeps the actions permanently visible beside the persistent endcap. */ @media (hover: none), (pointer: coarse) { - .sidebar-recent-session:not(.sidebar-recent-session--child):has( - > .sidebar-recent-session__aside > .session-row-state - ) - > .sidebar-recent-session__link { - padding-right: 112px; - } - - .sidebar-recent-session:not(.sidebar-recent-session--child) > .sidebar-recent-session__link { - mask-image: linear-gradient(to right, black calc(100% - 80px), transparent calc(100% - 52px)); + .sidebar-recent-session:not(.sidebar-recent-session--child) .sidebar-recent-session__details { + padding-right: 52px; } } @@ -4195,14 +4174,43 @@ html:not(.openclaw-native-macos):not(.openclaw-native-nav):not(.openclaw-native- min-width: 0; } +.sidebar-recent-session__details { + display: flex; + align-items: center; + gap: 6px; + height: 18px; + min-width: 0; + transition: padding-right var(--duration-fast) ease; +} + .sidebar-recent-session__subtitle { + flex: 1 1 auto; + min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--control-ui-text-xs); + line-height: 18px; color: color-mix(in srgb, var(--muted) 55%, var(--text) 45%); } +.sidebar-recent-session__details-endcap { + display: inline-flex; + align-items: center; + gap: 6px; + max-width: 100%; + height: 18px; + min-width: 0; + overflow: hidden; + flex: 0 1 auto; + margin-left: auto; + transition: opacity var(--duration-fast) ease; +} + +.sidebar-recent-session__details-endcap .session-row-state { + min-height: 18px; +} + .sidebar-session-attention__icon { display: inline-flex; align-items: center;