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``, + content: + attention.kind === "none" + ? html`` + : 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` - ${approvalNeeded - ? html` - ${icons.alertTriangle} - ` - : nothing} ${renderSessionRowBadges({ hasAutomation: false, outboxCount, hasComposerDraft })} ` : 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");