mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 12:56:01 -06:00
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:
@@ -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);
|
||||
|
||||
@@ -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("");
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user