fix(ui): show session attention on Home (#123759)

* fix(ui): show main-session attention on Home

* refactor(ui): keep Home attention boundary private
This commit is contained in:
Peter Steinberger
2026-08-14 11:27:04 -07:00
committed by GitHub
parent 17eb646bb3
commit da7a2b4651
7 changed files with 279 additions and 46 deletions
+16 -22
View File
@@ -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`<span class="nav-item__icon" aria-hidden="true">${icons.home}</span>`,
content:
attention.kind === "none"
? html`<span class="nav-item__icon" aria-hidden="true">${icons.home}</span>`
: 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`<openclaw-tooltip .content=${t("sessionsView.activeRun")}
${attentionLabel || running
? html`<openclaw-tooltip .content=${attentionLabel ?? t("sessionsView.activeRun")}
>${homeGlyph}</openclaw-tooltip
>`
: homeGlyph}
@@ -211,18 +215,8 @@ export function renderAppSidebarHomeRow(host: AppSidebarRenderHost) {
>
</openclaw-tooltip>`
: nothing}
${approvalNeeded || outboxCount > 0 || hasComposerDraft
${outboxCount > 0 || hasComposerDraft
? html`<span class="nav-item__state sidebar-home-session-states">
${approvalNeeded
? html`<openclaw-tooltip .content=${t("sessionsView.approvalNeeded")}>
<span
class="session-approval-badge"
role="img"
aria-label=${t("sessionsView.approvalNeeded")}
>${icons.alertTriangle}</span
>
</openclaw-tooltip>`
: nothing}
${renderSessionRowBadges({ hasAutomation: false, outboxCount, hasComposerDraft })}
</span>`
: nothing}
@@ -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;
@@ -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());
+181
View File
@@ -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> = {}): 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<void> {
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<number> {
await page.evaluate(
() =>
new Promise<void>((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 });
});
});
+1 -9
View File
@@ -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);
}
+1 -2
View File
@@ -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;
@@ -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");