diff --git a/ui/src/components/app-sidebar-render.ts b/ui/src/components/app-sidebar-render.ts
index 84f02e2652f3..fe26d69e402e 100644
--- a/ui/src/components/app-sidebar-render.ts
+++ b/ui/src/components/app-sidebar-render.ts
@@ -6,7 +6,6 @@ import {
type SidebarZoneEntry,
} from "../app-navigation.ts";
import { isSessionRouteId } from "../app-route-paths.ts";
-import { sessionHasPendingApproval } from "../app/approval-presentation.ts";
import { resolveControlUiAuthToken } from "../app/control-ui-auth.ts";
import { isNativeWebChromeHost } from "../app/native-web-chrome.ts";
import { readPresenceEntries, resolveCurrentSelfUser } from "../app/user-profile.ts";
@@ -31,6 +30,10 @@ import type { AppSidebarSessionNavigationElement } from "./app-sidebar-session-n
import type { SidebarRecentSession } from "./app-sidebar-session-types.ts";
import type { SidebarWorkboardBoard } from "./app-sidebar-workboard.ts";
import { icons } from "./icons.ts";
+import {
+ renderSessionAttentionIcon,
+ sessionAttentionSubtitle,
+} from "./session-attention-presentation.ts";
import { renderSessionGlyph, renderSessionUnreadBadge } from "./session-glyph.ts";
import { renderSessionRowBadges } from "./session-row-badges.ts";
import { formatSidebarBuildSubtitle } from "./sidebar-build-chip-format.ts";
@@ -156,21 +159,21 @@ export function renderAppSidebarHomeRow(host: AppSidebarRenderHost) {
const agentId = host.activeChipAgent().activeId;
const mainKey = host.selectedAgentMainSessionKey(agentId);
const mainRow = host.mainSessionRow(agentId);
- const approvalNeeded = sessionHasPendingApproval(
- host.sessionData.approvalBadgeSnapshot(),
- mainKey,
- );
- const outboxCount = host.outboxCountForSessionKey(mainKey);
+ const attention = host.resolveHomeSessionAttention(mainKey, mainRow);
+ const attentionLabel = sessionAttentionSubtitle(attention);
+ const outboxCount = host.outboxCountForSession(mainKey);
const active =
isSessionRouteId(host.activeRouteId) &&
areUiSessionKeysEquivalent(host.getRouteSessionKey(), mainKey);
const hasComposerDraft = !active && host.hasSessionDraft(mainKey);
const running = mainRow?.hasActiveRun === true;
const unread = mainRow?.unread === true && !active;
- // Home shares the sidebar's leading-slot contract: run state rings its icon
- // instead of drifting to the row edge, which stays reserved for counts.
+ // Home rings run state around its leading icon; the trailing slot stays reserved for counts.
const homeGlyph = renderSessionGlyph({
- content: html`${icons.home}`,
+ content:
+ attention.kind === "none"
+ ? html`${icons.home}`
+ : renderSessionAttentionIcon(attention),
running,
badge: unread ? renderSessionUnreadBadge() : nothing,
});
@@ -186,6 +189,7 @@ export function renderAppSidebarHomeRow(host: AppSidebarRenderHost) {
preferenceDerivedFace: true,
}).href}
class="nav-item nav-item--home ${active ? "nav-item--active" : ""}"
+ aria-label=${attentionLabel ? `${t("nav.home")} ยท ${attentionLabel}` : nothing}
aria-current=${active ? "page" : nothing}
@click=${(event: MouseEvent) => {
if (!shouldHandleNavigationClick(event)) {
@@ -195,8 +199,8 @@ export function renderAppSidebarHomeRow(host: AppSidebarRenderHost) {
host.openMainSession(agentId);
}}
>
- ${running
- ? html`${homeGlyph}`
: homeGlyph}
@@ -211,18 +215,8 @@ export function renderAppSidebarHomeRow(host: AppSidebarRenderHost) {
>
`
: nothing}
- ${approvalNeeded || outboxCount > 0 || hasComposerDraft
+ ${outboxCount > 0 || hasComposerDraft
? html``
: nothing}
diff --git a/ui/src/components/app-sidebar-session-navigation-logic.ts b/ui/src/components/app-sidebar-session-navigation-logic.ts
index 90f026ad18a8..cbaf92923882 100644
--- a/ui/src/components/app-sidebar-session-navigation-logic.ts
+++ b/ui/src/components/app-sidebar-session-navigation-logic.ts
@@ -1,7 +1,6 @@
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import type { GatewaySessionRow, SessionsListResult } from "../api/types.ts";
-import { SIDEBAR_NAV_ROUTES } from "../app-navigation.ts";
-import type { NavigationRouteId } from "../app-navigation.ts";
+import { SIDEBAR_NAV_ROUTES, type NavigationRouteId } from "../app-navigation.ts";
import type { RouteId } from "../app-route-paths.ts";
import type { ApplicationContext } from "../app/context.ts";
import { t } from "../i18n/index.ts";
@@ -49,6 +48,7 @@ import {
} from "./app-sidebar-session-types.ts";
import type { SidebarWorkboardBoard } from "./app-sidebar-workboard.ts";
import { resolveCloudWorkerStopAction } from "./cloud-worker-stop.ts";
+import type { SessionAttentionController } from "./session-attention-controller.ts";
import {
listSessionCreators,
type SessionCreatedActor,
@@ -57,6 +57,20 @@ import {
type SessionRow = SessionsListResult["sessions"][number];
+export function resolveSidebarHomeAttention(
+ attention: SessionAttentionController,
+ sessionKey: string,
+ row: GatewaySessionRow | null,
+) {
+ const known = attention
+ .knownSessionAttention()
+ .find((entry) => areUiSessionKeysEquivalent(entry.sessionKey, sessionKey));
+ return (
+ known?.attention ??
+ (row ? attention.resolveSessionAttention(row) : SIDEBAR_SESSION_NO_ATTENTION)
+ );
+}
+
export function compareSidebarSessionRowsByMode(input: {
a: SessionRow;
b: SessionRow;
diff --git a/ui/src/components/app-sidebar-session-navigation.ts b/ui/src/components/app-sidebar-session-navigation.ts
index ae01497fa87a..29d77b98a473 100644
--- a/ui/src/components/app-sidebar-session-navigation.ts
+++ b/ui/src/components/app-sidebar-session-navigation.ts
@@ -42,6 +42,7 @@ import {
partitionSidebarVisibleSections,
promoteSidebarSessionCreatedOrder,
resolveSidebarAgentChipSubtitle,
+ resolveSidebarHomeAttention,
resolveSidebarAgentResumeKey,
resolveSidebarMainSessionKey,
toggleSidebarSessionSelection,
@@ -272,10 +273,6 @@ export class AppSidebarSessionNavigationElement extends AppSidebarBase {
);
}
- outboxCountForSessionKey(sessionKey: string): number {
- return this.outboxCountForSession(sessionKey);
- }
-
getSessionNavigationState(): SidebarSessionNavigationState {
const routeSessionKey = this.getRouteSessionKey();
return buildSidebarSessionNavigationState({
@@ -291,7 +288,7 @@ export class AppSidebarSessionNavigationElement extends AppSidebarBase {
highlightCurrentSession: isSessionRouteId(this.activeRouteId),
runtimeSampledAtByRow: this.runtimeSampledAtByRow,
loadingChildSessionKeys: this.sessionData.loadingChildSessionKeys,
- outboxCountForSessionKey: (sessionKey) => this.outboxCountForSessionKey(sessionKey),
+ outboxCountForSessionKey: (sessionKey) => this.outboxCountForSession(sessionKey),
hasSessionDraft: (sessionKey) => this.hasSessionDraft(sessionKey),
resolveAttention: (row) => this.attention.resolveSessionAttention(row),
resolveAgentStatusNote: (row) => this.attention.resolveSessionAgentStatus(row)?.note,
@@ -725,6 +722,10 @@ export class AppSidebarSessionNavigationElement extends AppSidebarBase {
});
}
+ resolveHomeSessionAttention(sessionKey: string, row: GatewaySessionRow | null) {
+ return resolveSidebarHomeAttention(this.attention, sessionKey, row);
+ }
+
/** Gateway row backing the identity card (unread/running state), if loaded. */
mainSessionRow(agentId?: string): GatewaySessionRow | null {
const normalized = normalizeAgentId(agentId ?? this.expandedAgentId());
diff --git a/ui/src/e2e/home-attention.e2e.test.ts b/ui/src/e2e/home-attention.e2e.test.ts
new file mode 100644
index 000000000000..7b94116ce2b5
--- /dev/null
+++ b/ui/src/e2e/home-attention.e2e.test.ts
@@ -0,0 +1,181 @@
+import { mkdir } from "node:fs/promises";
+import path from "node:path";
+import type { QuestionRecord } from "@openclaw/gateway-protocol";
+import { expect, it } from "vitest";
+import type { GatewaySessionRow, SessionsListResult } from "../api/types.ts";
+import {
+ controlUiSessionUrl,
+ installMockGateway,
+ type MockGatewayControls,
+} from "../test-helpers/control-ui-e2e.ts";
+import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts";
+
+const suite = createControlUiE2eSuite({
+ name: "Control UI Home attention",
+ startServerBeforeBrowser: true,
+ trackBrowserContexts: true,
+ unavailableMessage: (executablePath) =>
+ `Playwright Chromium is not available at ${executablePath}`,
+});
+
+const mainSessionKey = "agent:main:main";
+const captureUiProof = process.env.OPENCLAW_CAPTURE_UI_PROOF === "1";
+const proofVariant = process.env.OPENCLAW_HOME_ATTENTION_PROOF_VARIANT || "candidate";
+const proofDir = path.join(
+ process.cwd(),
+ ".artifacts",
+ "control-ui-e2e",
+ "home-attention",
+ proofVariant,
+);
+
+function sessionsList(row: GatewaySessionRow): SessionsListResult {
+ return {
+ ts: Date.now(),
+ path: "",
+ count: 1,
+ defaults: { modelProvider: "openai", model: "gpt-5.6-luna", contextTokens: null },
+ sessions: [row],
+ };
+}
+
+function sessionRow(overrides: Partial = {}): GatewaySessionRow {
+ return {
+ key: mainSessionKey,
+ kind: "direct",
+ label: "Home",
+ displayName: "Quiet control state",
+ updatedAt: Date.now(),
+ status: "done",
+ ...overrides,
+ };
+}
+
+async function publishSessionRow(
+ gateway: MockGatewayControls,
+ row: GatewaySessionRow,
+): Promise {
+ await gateway.setMethodResponse("sessions.list", sessionsList(row));
+ const requestCount = (await gateway.getRequests("sessions.list")).length;
+ await gateway.emitGatewayEvent("sessions.changed", {
+ key: row.key,
+ reason: "home-attention-proof",
+ updatedAt: row.updatedAt,
+ });
+ await expect
+ .poll(async () => (await gateway.getRequests("sessions.list")).length)
+ .toBeGreaterThan(requestCount);
+}
+
+async function captureState(
+ page: import("playwright").Page,
+ home: import("playwright").Locator,
+ name: string,
+): Promise {
+ await page.evaluate(
+ () =>
+ new Promise((resolve) => {
+ requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
+ }),
+ );
+ if (captureUiProof) {
+ await page.screenshot({
+ animations: "disabled",
+ fullPage: true,
+ path: path.join(proofDir, `${name}.png`),
+ });
+ }
+ return home.locator("[data-session-attention]").count();
+}
+
+suite.define(() => {
+ it("projects question, failure, and agent-declared attention onto Home", async () => {
+ if (captureUiProof) {
+ await mkdir(proofDir, { recursive: true });
+ }
+ const context = await suite.newBrowserContext({
+ locale: "en-US",
+ serviceWorkers: "block",
+ viewport: { height: 900, width: 1280 },
+ ...(captureUiProof
+ ? { recordVideo: { dir: proofDir, size: { height: 900, width: 1280 } } }
+ : {}),
+ });
+ const page = await context.newPage();
+ const gateway = await installMockGateway(page, {
+ featureMethods: ["question.get", "question.list", "question.resolve"],
+ methodResponses: {
+ "question.list": { questions: [] },
+ "sessions.list": sessionsList(sessionRow()),
+ },
+ sessionKey: mainSessionKey,
+ });
+
+ await page.goto(controlUiSessionUrl(suite.server.baseUrl, mainSessionKey));
+ await gateway.waitForRequest("sessions.list");
+ const home = page.locator(".nav-item--home");
+ await home.waitFor();
+ const observed = {
+ quiet: await captureState(page, home, "00-non-attention"),
+ agent: 0,
+ question: 0,
+ error: 0,
+ };
+
+ await publishSessionRow(
+ gateway,
+ sessionRow({
+ displayName: "Declared attention state",
+ status: "running",
+ hasActiveRun: true,
+ agentStatus: {
+ note: "Blocked: operator input required",
+ attention: "flag",
+ expiresAt: Date.now() + 60_000,
+ },
+ }),
+ );
+ observed.agent = await captureState(page, home, "01-agent-declared-attention");
+
+ const now = Date.now();
+ const question = {
+ id: "home-attention-question",
+ agentId: "main",
+ sessionKey: mainSessionKey,
+ questions: [
+ {
+ questionId: "continue_run",
+ header: "Continue",
+ question: "Should the run continue?",
+ options: [{ label: "Continue", description: "Resume the waiting run." }],
+ },
+ ],
+ createdAtMs: now,
+ expiresAtMs: now + 60_000,
+ status: "pending",
+ } satisfies QuestionRecord;
+ await gateway.emitGatewayEvent("question.requested", question);
+ await page.getByText("Should the run continue?", { exact: true }).waitFor();
+ observed.question = await captureState(page, home, "02-question-attention");
+
+ await gateway.emitGatewayEvent("question.resolved", {
+ id: question.id,
+ status: "cancelled",
+ });
+ await expect
+ .poll(() => page.getByText("Should the run continue?", { exact: true }).count())
+ .toBe(0);
+ await publishSessionRow(
+ gateway,
+ sessionRow({
+ displayName: "Failed run state",
+ endedAt: Date.now(),
+ lastRunError: "Release validation failed",
+ status: "failed",
+ }),
+ );
+ observed.error = await captureState(page, home, "03-failed-run-attention");
+
+ expect(observed).toEqual({ quiet: 0, agent: 1, question: 1, error: 1 });
+ });
+});
diff --git a/ui/src/styles/components.css b/ui/src/styles/components.css
index 7463b7ea7ae0..aac38f57c992 100644
--- a/ui/src/styles/components.css
+++ b/ui/src/styles/components.css
@@ -5630,20 +5630,12 @@ td.data-table-key-col {
color: var(--accent);
}
-.session-row-badge--approval,
-.session-approval-badge {
+.session-row-badge--approval {
display: inline-flex;
align-items: center;
color: var(--warn);
}
-.session-approval-badge svg {
- width: 13px;
- height: 13px;
- stroke: currentColor;
- fill: none;
-}
-
.session-row-badge--cloud[data-placement-state="failed"] {
color: var(--danger);
}
diff --git a/ui/src/styles/layout.css b/ui/src/styles/layout.css
index 524edbe8dff5..498ff3831b56 100644
--- a/ui/src/styles/layout.css
+++ b/ui/src/styles/layout.css
@@ -3151,8 +3151,7 @@ wa-dropdown-item.session-menu__item::part(submenu-icon) {
stroke-linejoin: round;
}
-/* Home carries approval and outbox counts at the row's edge; run/unread state
- rings the leading icon instead (see .session-glyph). */
+/* Home keeps outbox/draft counts at the edge; run/unread rings the leading icon. */
.nav-item__state {
flex: none;
margin-left: auto;
diff --git a/ui/src/test-helpers/app-sidebar-cases/attention.ts b/ui/src/test-helpers/app-sidebar-cases/attention.ts
index 182b782ed84b..c76962344554 100644
--- a/ui/src/test-helpers/app-sidebar-cases/attention.ts
+++ b/ui/src/test-helpers/app-sidebar-cases/attention.ts
@@ -59,6 +59,57 @@ function agentAttentionRow(
}
describe("AppSidebar session attention", () => {
+ it("projects canonical attention onto Home across row refresh ordering", async () => {
+ const mainKey = "agent:main:main";
+ const client = {
+ request: vi.fn().mockResolvedValue({ questions: [] }),
+ } as unknown as GatewayBrowserClient;
+ const gatewayHarness = createGatewayHarness(client);
+ const sessionsHarness = createSessionsHarness("main", [mainKey]);
+ setRows(sessionsHarness, [agentAttentionRow(mainKey)]);
+ const { sidebar } = await mountSidebar(gatewayHarness.gateway, sessionsHarness.sessions);
+ const home = sidebar.querySelector(".nav-item--home");
+
+ expect(home?.querySelector('[data-session-attention="agent"]')).not.toBeNull();
+
+ gatewayHarness.publishEvent("question.requested", {
+ id: "question-home",
+ agentId: "main",
+ sessionKey: mainKey,
+ questions: [
+ {
+ questionId: "confirm",
+ header: "Confirm",
+ question: "Continue?",
+ options: [{ label: "Continue", description: "Resume the run." }],
+ },
+ ],
+ createdAtMs: Date.now(),
+ expiresAtMs: Date.now() + 60_000,
+ status: "pending",
+ });
+ await sidebar.updateComplete;
+ expect(home?.querySelector('[data-session-attention="question"]')).not.toBeNull();
+
+ setRows(sessionsHarness, []);
+ await sidebar.updateComplete;
+ expect(home?.querySelector('[data-session-attention="question"]')).not.toBeNull();
+
+ gatewayHarness.publishEvent("question.resolved", {
+ id: "question-home",
+ status: "cancelled",
+ });
+ setRows(sessionsHarness, [failedRow(mainKey)]);
+ await sidebar.updateComplete;
+ expect(home?.querySelector('[data-session-attention="error"]')).not.toBeNull();
+
+ setRows(sessionsHarness, [
+ { key: mainKey, kind: "direct", label: "Home", updatedAt: 3, status: "done" },
+ ]);
+ await sidebar.updateComplete;
+ expect(home?.querySelector("[data-session-attention]")).toBeNull();
+ });
+
it("shows question attention ahead of a run error and clears it on resolution", async () => {
const client = {
request: vi.fn().mockResolvedValue({ questions: [] }),
@@ -180,7 +231,7 @@ describe("AppSidebar session attention", () => {
expect(sidebar.textContent).not.toContain("Run failed:");
});
- it("uses shared tooltips for Home and agent approval badges", async () => {
+ it("uses canonical Home attention and shared agent approval tooltips", async () => {
const mainKey = "agent:main:main";
const approval = {
id: "approval-main",
@@ -197,13 +248,14 @@ describe("AppSidebar session attention", () => {
[approval],
);
- const homeBadge = sidebar.querySelector(".nav-item--home .session-approval-badge");
- expect(homeBadge?.getAttribute("aria-label")).toBe("Approval needed");
- expect(homeBadge?.hasAttribute("title")).toBe(false);
+ const homeAttention = sidebar.querySelector(
+ '.nav-item--home [data-session-attention="approval"]',
+ );
+ expect(homeAttention).not.toBeNull();
expect(
- (homeBadge?.closest("openclaw-tooltip") as (HTMLElement & { content?: string }) | null)
+ (homeAttention?.closest("openclaw-tooltip") as (HTMLElement & { content?: string }) | null)
?.content,
- ).toBe("Approval needed");
+ ).toBe("Waiting for approval");
const agentBadge = sidebar.querySelector(".sidebar-agent-card__approval-count");
const agentLabel = agentBadge?.getAttribute("aria-label");