fix(ui): page activity indicator matches session rows (#123813)

* fix(ui): align page working indicator with sessions

* test(ui): align lifecycle checks with home spinner
This commit is contained in:
Jason (Json)
2026-08-14 16:58:44 -06:00
committed by GitHub
parent 47e683ad27
commit 89a424bcb9
6 changed files with 83 additions and 40 deletions
+7 -7
View File
@@ -32,6 +32,7 @@ import type { SidebarWorkboardBoard } from "./app-sidebar-workboard.ts";
import { icons } from "./icons.ts";
import {
renderSessionAttentionIcon,
renderSessionRunSpinner,
sessionAttentionSubtitle,
} from "./session-attention-presentation.ts";
import { renderSessionGlyph, renderSessionUnreadBadge } from "./session-glyph.ts";
@@ -168,13 +169,13 @@ export function renderAppSidebarHomeRow(host: AppSidebarRenderHost) {
const hasComposerDraft = !active && host.hasSessionDraft(mainKey);
const running = mainRow?.hasActiveRun === true;
const unread = mainRow?.unread === true && !active;
// Home rings run state around its leading icon; the trailing slot stays reserved for counts.
// Home keeps its page/attention glyph leading and shares trailing activity with session rows.
const homeGlyph = renderSessionGlyph({
content:
attention.kind === "none"
? html`<span class="nav-item__icon" aria-hidden="true">${icons.home}</span>`
: renderSessionAttentionIcon(attention),
running,
running: false,
badge: unread ? renderSessionUnreadBadge() : nothing,
});
return html`
@@ -199,10 +200,8 @@ export function renderAppSidebarHomeRow(host: AppSidebarRenderHost) {
host.openMainSession(agentId);
}}
>
${attentionLabel || running
? html`<openclaw-tooltip .content=${attentionLabel ?? t("sessionsView.activeRun")}
>${homeGlyph}</openclaw-tooltip
>`
${attentionLabel
? html`<openclaw-tooltip .content=${attentionLabel}>${homeGlyph}</openclaw-tooltip>`
: homeGlyph}
<span class="nav-item__text">${t("nav.home")}</span>
${sessionHasBoard(mainKey)
@@ -215,8 +214,9 @@ export function renderAppSidebarHomeRow(host: AppSidebarRenderHost) {
>
</openclaw-tooltip>`
: nothing}
${outboxCount > 0 || hasComposerDraft
${running || outboxCount > 0 || hasComposerDraft
? html`<span class="nav-item__state sidebar-home-session-states">
${running ? renderSessionRunSpinner() : nothing}
${renderSessionRowBadges({ hasAutomation: false, outboxCount, hasComposerDraft })}
</span>`
: nothing}
@@ -51,14 +51,18 @@ export function renderSessionUnreadState(session: SidebarRecentSession) {
: nothing;
}
export function renderSessionRunSpinner(showTitle = true) {
return html`<span
class="session-run-spinner sidebar-recent-session__state"
role="img"
aria-label=${t("sessionsView.activeRun")}
title=${showTitle ? t("sessionsView.activeRun") : nothing}
></span>`;
}
export function renderSessionState(session: SidebarRecentSession, showTitle = true) {
if (session.hasActiveRun) {
return html`<span
class="session-run-spinner sidebar-recent-session__state"
role="img"
aria-label=${t("sessionsView.activeRun")}
title=${showTitle ? t("sessionsView.activeRun") : nothing}
></span>`;
return renderSessionRunSpinner(showTitle);
}
if (!session.isChild) {
return renderSessionUnreadState(session);
+17 -15
View File
@@ -11,10 +11,6 @@ const suite = createControlUiE2eSuite({
name: "Control UI chat run lifecycle",
});
// Home mirrors the sidebar leading-slot contract: an active run rings the Home
// glyph instead of adding a trailing spinner (see app-sidebar-render.ts).
const HOME_RUN_RING_SELECTOR = ".session-glyph--running .session-glyph__ring";
// Browser contexts preserve test isolation; keep one process warm for this file.
let page: Page | undefined;
suite.define(() => {
@@ -182,7 +178,10 @@ suite.define(() => {
await currentPage.getByRole("button", { name: "Stop generating" }).waitFor();
const mainSession = currentPage.locator(".nav-item--home");
const mainSessionRunRing = mainSession.locator(HOME_RUN_RING_SELECTOR);
// Home mirrors session rows: active-run state lives in the trailing metadata endcap.
const mainSessionRunIndicator = mainSession
.locator(".nav-item__state")
.getByRole("img", { name: "Active run" });
await mainSession.waitFor({ state: "visible" });
const sessionListsBeforeActive = (await gateway.getRequests("sessions.list")).length;
await gateway.deferNext("sessions.list");
@@ -201,11 +200,11 @@ suite.define(() => {
await expect
.poll(async () => (await gateway.getRequests("sessions.list")).length)
.toBeGreaterThan(sessionListsBeforeActive);
await mainSessionRunRing.waitFor();
await mainSessionRunIndicator.waitFor();
await gateway.emitChatFinal({ runId, text: "Run complete." });
await currentPage.locator(".chat-bubble").getByText("Run complete.", { exact: true }).waitFor();
await expect.poll(() => mainSessionRunRing.count()).toBe(0);
await expect.poll(() => mainSessionRunIndicator.count()).toBe(0);
const staleActiveLabel = "Main stale active snapshot";
await gateway.resolveDeferred("sessions.list", {
count: 1,
@@ -230,7 +229,7 @@ suite.define(() => {
});
await currentPage.locator(".chat-pane__session-title", { hasText: staleActiveLabel }).waitFor();
expect(await currentPage.getByRole("button", { name: "Stop generating" }).count()).toBe(0);
await expect.poll(() => mainSessionRunRing.count()).toBe(0);
await expect.poll(() => mainSessionRunIndicator.count()).toBe(0);
const sessionListsBeforeStaleActive = (await gateway.getRequests("sessions.list")).length;
await gateway.deferNext("sessions.list");
@@ -248,12 +247,12 @@ suite.define(() => {
.poll(async () => (await gateway.getRequests("sessions.list")).length)
.toBeGreaterThan(sessionListsBeforeStaleActive);
expect(await currentPage.getByRole("button", { name: "Stop generating" }).count()).toBe(0);
await expect.poll(() => mainSessionRunRing.count()).toBe(0);
await expect.poll(() => mainSessionRunIndicator.count()).toBe(0);
await gateway.resolveDeferred("sessions.list");
await currentPage.clock.fastForward(CHAT_RUN_STATUS_TOAST_DURATION_MS + 250);
expect(await currentPage.getByRole("button", { name: "Stop generating" }).count()).toBe(0);
expect(await mainSessionRunRing.count()).toBe(0);
expect(await mainSessionRunIndicator.count()).toBe(0);
// Event timestamps must follow the page's virtual clock so freshness checks
// see the same elapsed suppression window that the UI just observed.
@@ -271,7 +270,7 @@ suite.define(() => {
.poll(async () => (await gateway.getRequests("sessions.list")).length)
.toBeGreaterThan(sessionListsBeforeOtherSession);
expect(await currentPage.getByRole("button", { name: "Stop generating" }).count()).toBe(0);
await expect.poll(() => mainSessionRunRing.count()).toBe(0);
await expect.poll(() => mainSessionRunIndicator.count()).toBe(0);
await gateway.resolveDeferred("sessions.list");
// Re-publish after the former 10-second suppression window. The completed
@@ -294,7 +293,7 @@ suite.define(() => {
.poll(async () => (await gateway.getRequests("sessions.list")).length)
.toBeGreaterThan(sessionListsBeforeLateStaleActive);
expect(await currentPage.getByRole("button", { name: "Stop generating" }).count()).toBe(0);
await expect.poll(() => mainSessionRunRing.count()).toBe(0);
await expect.poll(() => mainSessionRunIndicator.count()).toBe(0);
await gateway.resolveDeferred("sessions.list");
});
@@ -326,7 +325,10 @@ suite.define(() => {
await currentPage.getByRole("button", { name: "Stop generating" }).waitFor();
const mainSession = currentPage.locator(".nav-item--home");
const mainSessionRunRing = mainSession.locator(HOME_RUN_RING_SELECTOR);
// Home mirrors session rows: active-run state lives in the trailing metadata endcap.
const mainSessionRunIndicator = mainSession
.locator(".nav-item__state")
.getByRole("img", { name: "Active run" });
await mainSession.waitFor({ state: "visible" });
const sessionListsBeforeActive = (await gateway.getRequests("sessions.list")).length;
await gateway.deferNext("sessions.list");
@@ -343,7 +345,7 @@ suite.define(() => {
await expect
.poll(async () => (await gateway.getRequests("sessions.list")).length)
.toBeGreaterThan(sessionListsBeforeActive);
await mainSessionRunRing.waitFor();
await mainSessionRunIndicator.waitFor();
const finalText = "The gateway will restart; I will resume verification afterward.";
await gateway.emitGatewayEvent("chat", {
@@ -361,7 +363,7 @@ suite.define(() => {
await currentPage.locator(".chat-thread-inner").getByText(finalText, { exact: true }).waitFor();
expect(await currentPage.getByRole("button", { name: "Stop generating" }).count()).toBe(0);
await expect.poll(() => mainSessionRunRing.count()).toBe(0);
await expect.poll(() => mainSessionRunIndicator.count()).toBe(0);
await expect
.poll(() => currentPage.locator(".agent-chat__run-status-announcement").textContent())
.toBe("");
+1 -1
View File
@@ -3151,7 +3151,7 @@ wa-dropdown-item.session-menu__item::part(submenu-icon) {
stroke-linejoin: round;
}
/* Home keeps outbox/draft counts at the edge; run/unread rings the leading icon. */
/* Home keeps run state plus outbox/draft counts together at the row edge. */
.nav-item__state {
flex: none;
margin-left: auto;
@@ -480,17 +480,14 @@ describe("AppSidebar agent chip", () => {
expect(sidebar.querySelector(".sidebar-agent-card__subtitle")?.textContent).toContain(
"Working",
);
// Run state rings the Home icon in the leading slot; the row edge keeps
// only approval/outbox counts.
const ring = sidebar.querySelector(
".nav-item--home .session-glyph--running .session-glyph__ring",
);
expect(ring).not.toBeNull();
expect(sidebar.querySelector(".nav-item--home .nav-item__state")).toBeNull();
expect(ring?.hasAttribute("title")).toBe(false);
expect(
(ring?.closest("openclaw-tooltip") as (HTMLElement & { content?: string }) | null)?.content,
).toBe("Active run");
// Run state uses the session spinner at the row edge without changing the Home icon.
const spinner = sidebar.querySelector(".nav-item--home .nav-item__state .session-run-spinner");
expect(spinner).not.toBeNull();
expect(sidebar.querySelector(".nav-item--home .nav-item__icon")).not.toBeNull();
expect(sidebar.querySelector(".nav-item--home .session-glyph__ring")).toBeNull();
expect(spinner?.getAttribute("role")).toBe("img");
expect(spinner?.getAttribute("aria-label")).toBe("Active run");
expect(spinner?.getAttribute("title")).toBe("Active run");
});
it("uses the shared tooltip for the Home dashboard glyph", async () => {
@@ -11,6 +11,46 @@ function expectNoLead(row: Element | null) {
}
describe("AppSidebar session indicators", () => {
it("places Home activity in the same trailing endcap as session activity", async () => {
const mainKey = "agent:main:main";
const workingKey = "agent:main:working";
const sessions = createSessionsHarness("main", [mainKey, workingKey]);
const result = sessions.sessions.state.result;
if (!result) {
throw new Error("expected session list");
}
for (const row of result.sessions) {
row.hasActiveRun = true;
row.status = "running";
}
const { sidebar } = await mountSidebar(
createGatewayHarness({} as GatewayBrowserClient).gateway,
sessions.sessions,
);
sidebar.outboxCountForSession = (sessionKey) => (sessionKey === mainKey ? 2 : 0);
sidebar.hasSessionDraft = (sessionKey) => sessionKey === mainKey;
sidebar.requestUpdate();
await sidebar.updateComplete;
const home = sidebar.querySelector(".nav-item--home");
const workingSession = sidebar.querySelector(`[data-session-key="${workingKey}"]`);
const homeSpinner = home?.querySelector(".nav-item__state .session-run-spinner");
const sessionSpinner = workingSession?.querySelector(".session-row-aside .session-run-spinner");
expect(home?.querySelector(".nav-item__icon")).not.toBeNull();
expect(home?.querySelector(".session-glyph__ring")).toBeNull();
expect(homeSpinner).not.toBeNull();
expect(homeSpinner?.className).toBe(sessionSpinner?.className);
expect(homeSpinner?.getAttribute("role")).toBe(sessionSpinner?.getAttribute("role"));
expect(homeSpinner?.getAttribute("aria-label")).toBe(
sessionSpinner?.getAttribute("aria-label"),
);
expect(
home?.querySelector(".nav-item__state .session-row-badge--queued")?.textContent,
).toContain("2");
expect(home?.querySelector(".nav-item__state .session-row-badge--draft")).not.toBeNull();
});
it("preserves child PR indicators and leads a pinned child like any other", async () => {
const parentKey = "agent:main:parent";
const pinnedKey = "agent:main:pinned-child";