mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 04:47:03 -06:00
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:
committed by
GitHub
parent
17eb646bb3
commit
da7a2b4651
@@ -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());
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user