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(
- ${renderSessionRowBadges({
- hasAutomation: false,
- pullRequest: session.pullRequest,
- })}
- ${!session.isChild && sessionHasBoard(session.key)
- ? html``
- : nothing}
-
- ${renderSessionRowBadges({
- ...session,
- hasComposerDraft: session.hasComposerDraft === true && !session.visuallyActive,
- pullRequest: session.pullRequest ?? display?.pullRequest,
- hasApproval: sessionHasPendingApproval(
- host.sessionData.approvalBadgeSnapshot(),
- session.key,
- ),
- })}
${session.childSessionKeys.length > 0
? html``
: nothing}
- `}
`;
// 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;