fix(ui): restore approved inbox panel layout after lazy-chunk split (#128201)

* fix(ui): restore Inbox panel spacing

* fix(ui): stabilize Inbox update state

* fix(ui): keep updates in Inbox

* fix(ui): keep update status authoritative

* test(ui): add gateway-shaped update fixtures

* fix(ui): preserve read-only update campaign visibility

* fix(ui): preserve update handoff after Inbox closes

* fix(ui): keep floating updates Inbox-owned

* refactor(ui): isolate update schedule projection
This commit is contained in:
Vyctor H. Brzezowski
2026-08-23 11:22:51 -03:00
committed by GitHub
parent bbdf7abcff
commit 5000f32b8d
22 changed files with 631 additions and 374 deletions
+186 -66
View File
@@ -17,7 +17,9 @@ import { applySharedChannelFieldHelp } from "../src/config/schema.channel-field-
import { buildBaseHints } from "../src/config/schema.hints.js";
import { applyConfigTierHints, applyResolvedConfigTierHints } from "../src/config/schema.tiers.js";
import { CONTROL_UI_BOOTSTRAP_CONFIG_PATH } from "../src/gateway/control-ui-contract.js";
import type { UpdateScheduleState } from "../ui/src/api/types.ts";
import { buildUpdateRestartSentinelPayload } from "../src/infra/update-restart-sentinel-payload.js";
import type { UpdateRunResult } from "../src/infra/update-runner.js";
import type { UpdateAvailable, UpdateScheduleState } from "../ui/src/api/types.ts";
import {
createControlUiMockBootstrapConfig,
createControlUiMockGatewayInitScript,
@@ -40,7 +42,15 @@ import { buildSkillWorkshopMocks } from "./control-ui-mock-skill-workshop.js";
type CliOptions = {
allowedHosts: string[];
fixture?: "approval" | "board" | "code-fences" | "swarm" | "workboard";
fixture?:
| "approval"
| "board"
| "code-fences"
| "swarm"
| "update-available"
| "update-blocked"
| "update-failed"
| "workboard";
host: string;
operatorScopes?: string[];
port: number;
@@ -81,6 +91,171 @@ const PLAN_DEMO_RUN_ID = "mock-plan-run";
const CUSTODIAN_CHAT_REPLY_DELAY_MS = 600;
const CHAT_SEND_REPLY_DELAY_MS = 200;
type UpdateFixture = {
available: UpdateAvailable;
runResponse: unknown;
schedule: UpdateScheduleState;
statusResponse: unknown;
};
function buildUpdateFixture(fixture: CliOptions["fixture"], nowMs: number): UpdateFixture | null {
if (
fixture !== "update-available" &&
fixture !== "update-blocked" &&
fixture !== "update-failed"
) {
return null;
}
if (fixture === "update-available") {
const available: UpdateAvailable = {
currentVersion: "2026.8.1",
latestVersion: "2026.8.2",
channel: "latest",
};
const schedule: UpdateScheduleState = {
channel: "stable",
autoEnabled: false,
install: { kind: "package" },
target: { kind: "package", version: available.latestVersion },
};
return {
available,
schedule,
statusResponse: {
sentinel: null,
updateAvailable: available,
effectiveChannel: "stable",
schedule,
},
runResponse: {
ok: true,
result: {
status: "ok",
mode: "global",
before: { version: available.currentVersion },
after: { version: available.latestVersion },
steps: [],
durationMs: 12_000,
},
},
};
}
const currentSha = "83b321ba7d31c04cc6f7a38c87932ec0172b5461";
const upstreamSha = "ea2ab707d3ab6f9351dcdb2f3c05054097fbfa62";
const available: UpdateAvailable = {
currentVersion: "2026.8.1",
latestVersion: "2026.8.1",
channel: "dev",
currentSha,
upstreamRef: "origin/main",
upstreamSha,
commitsBehind: 2,
commits: [
{ sha: "f6c71c4", subject: "Keep update status authoritative" },
{ sha: "ea2ab70", subject: "Move update actions into Inbox" },
],
};
const baseSchedule: UpdateScheduleState = {
channel: "dev",
autoEnabled: true,
install: {
kind: "git",
git: {
status: "behind",
currentSha,
commitsBehind: 2,
commitAtMs: nowMs - 2 * 86_400_000,
installedAtMs: nowMs - 7 * 86_400_000,
},
},
target: {
kind: "git",
upstreamRef: available.upstreamRef ?? "origin/main",
upstreamSha,
commitsBehind: 2,
},
};
if (fixture === "update-blocked") {
const schedule: UpdateScheduleState = {
...baseSchedule,
campaign: {
id: "mock-update-waiting-for-idle",
state: "waiting-for-idle",
announcedAtMs: nowMs - 2 * 60_000,
forceAtMs: nowMs + 13 * 60_000,
updatedAtMs: nowMs,
},
};
return {
available,
schedule,
statusResponse: {
sentinel: null,
updateAvailable: available,
effectiveChannel: "dev",
schedule,
},
runResponse: {
ok: true,
result: {
status: "skipped",
mode: "git",
reason: "managed-service-handoff-started",
before: { version: available.currentVersion, sha: currentSha },
steps: [],
durationMs: 0,
},
handoff: { status: "started" },
},
};
}
const schedule: UpdateScheduleState = {
...baseSchedule,
campaign: {
id: "mock-update-before-failure",
state: "waiting-for-idle",
announcedAtMs: nowMs - 2 * 60_000,
forceAtMs: nowMs + 13 * 60_000,
updatedAtMs: nowMs,
},
};
const result: UpdateRunResult = {
status: "error",
mode: "git",
root: "/mock/openclaw",
reason: "build-failed",
before: { version: available.currentVersion, sha: currentSha },
after: { version: available.latestVersion, sha: upstreamSha },
steps: [
{
name: "build",
command: "pnpm build",
cwd: "/mock/openclaw",
durationMs: 8_420,
stdoutTail: "",
stderrTail: "tsc: error TS2345",
exitCode: 1,
},
],
durationMs: 11_640,
};
return {
available,
schedule,
runResponse: { ok: false, result },
statusResponse: {
sentinel: buildUpdateRestartSentinelPayload({ result, meta: {}, nowMs }),
updateAvailable: available,
effectiveChannel: "dev",
schedule: baseSchedule,
},
};
}
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const uiRoot = path.join(repoRoot, "ui");
const boardFixturePath = "/__fixtures/board/";
@@ -175,6 +350,9 @@ function parseFixture(value: string | undefined): CliOptions["fixture"] {
value !== "board" &&
value !== "code-fences" &&
value !== "swarm" &&
value !== "update-available" &&
value !== "update-blocked" &&
value !== "update-failed" &&
value !== "workboard"
) {
throw new Error(`Unknown Control UI mock fixture: ${value}`);
@@ -1645,30 +1823,8 @@ async function createChatPickerScenario(
const richAttention = fixture === "approval";
const cronMocks = buildCronMocks(Date.now(), { richAttention });
const updateFixtureNow = Date.now();
const updateSchedule: UpdateScheduleState | null = richAttention
? {
channel: "dev",
autoEnabled: true,
install: {
kind: "git",
git: { status: "behind", commitsBehind: 2 },
},
target: {
kind: "git",
upstreamRef: "origin/main",
upstreamSha: "a".repeat(40),
commitsBehind: 2,
},
campaign: {
id: "mock-update-campaign",
state: "waiting-for-idle",
announcedAtMs: updateFixtureNow - 2 * 60_000,
applyAtMs: updateFixtureNow + 15 * 60_000,
forceAtMs: updateFixtureNow + 60 * 60_000,
updatedAtMs: updateFixtureNow,
},
}
: null;
const updateFixture = buildUpdateFixture(fixture, updateFixtureNow);
const updateSchedule = updateFixture?.schedule ?? null;
const heldUpdateSchedule: UpdateScheduleState | null = updateSchedule?.campaign
? {
...updateSchedule,
@@ -1793,20 +1949,7 @@ async function createChatPickerScenario(
defaultAgentId: "main",
serverBuildId: "mock",
updateSchedule,
updateAvailable:
fixture === "approval"
? {
channel: "dev",
currentVersion: "2026.8.1",
latestVersion: "2026.8.1",
upstreamSha: "a".repeat(40),
commitsBehind: 2,
commits: [
{ sha: "abcdef1234567", subject: "Unify sidebar notifications" },
{ sha: "fedcba7654321", subject: "Keep the footer identity compact" },
],
}
: null,
updateAvailable: updateFixture?.available ?? null,
// Advertised Gateway methods gate session actions (see
// ui/src/lib/session-method-access.ts). Omitting the mutation methods left
// every session context-menu row disabled, so the harness could not show
@@ -1839,7 +1982,7 @@ async function createChatPickerScenario(
"sessions.create",
"system.info",
"terminal.open",
...(richAttention ? ["update.hold", "update.run", "update.status"] : []),
...(updateFixture ? ["update.hold", "update.run", "update.status"] : []),
...(fixture === "workboard"
? [
"board.get",
@@ -2358,31 +2501,8 @@ async function createChatPickerScenario(
"update.hold": heldUpdateSchedule
? { ok: true, schedule: heldUpdateSchedule }
: { ok: false },
"update.run": updateSchedule
? { ok: false, result: { status: "error", reason: "build-dirty" } }
: {},
"update.status": updateSchedule
? {
sentinel: {
kind: "update",
status: "error",
ts: updateFixtureNow,
stats: {
reason: "build-dirty",
steps: [
{
name: "build",
log: {
exitCode: 1,
stderrTail:
"generated artifacts differ from the selected revision after the build completed; preserve the checkout and retry only after reconciling the generated files and verifying the target revision",
},
},
],
},
},
}
: {},
"update.run": updateFixture?.runResponse ?? {},
"update.status": updateFixture?.statusResponse ?? {},
"usage.status": modelProviders.usageStatus,
"device.pair.list": {
paired: [
+7 -15
View File
@@ -424,7 +424,7 @@ describe("OpenClaw native shell", () => {
});
describe("OpenClaw shell update affordance", () => {
it("renders floating attention and loud update states only while navigation is collapsed", async () => {
it("renders floating attention while keeping update actions in navigation", async () => {
const container = document.createElement("div");
document.body.append(container);
const shared = {
@@ -448,21 +448,10 @@ describe("OpenClaw shell update affordance", () => {
mobileNavLayout: false,
});
render(renderFloatingUpdateCard({ ...shared, navigationSurfaceHidden: collapsed }), container);
const card = container.querySelector<
HTMLElement & {
canUpdate: boolean;
onRefresh: () => void;
refreshRequired: boolean;
updateComplete: Promise<boolean>;
}
>("openclaw-sidebar-update-card");
expect(card).not.toBeNull();
expect(
container.querySelector("openclaw-sidebar-attention.sidebar-attention--floating"),
).not.toBeNull();
await card?.updateComplete;
expect(card?.canUpdate).toBe(true);
expect(card?.querySelector(".sidebar-update-card")).toBeNull();
expect(container.querySelector("openclaw-sidebar-update-card")).toBeNull();
render(
renderFloatingUpdateCard({
@@ -473,8 +462,11 @@ describe("OpenClaw shell update affordance", () => {
}),
container,
);
expect(card?.refreshRequired).toBe(true);
card?.onRefresh();
const refreshCard = container.querySelector<
HTMLElement & { onRefresh: () => void; refreshRequired: boolean }
>("openclaw-sidebar-update-card");
expect(refreshCard?.refreshRequired).toBe(true);
refreshCard?.onRefresh();
expect(shared.onRefresh).toHaveBeenCalledOnce();
expect(shared.onUpdate).not.toHaveBeenCalled();
-12
View File
@@ -375,19 +375,7 @@ export function renderApplicationShell(host: ShellViewHost) {
lobsterPetSounds: uiSettings.lobsterPetSounds === true,
gatewayVersion: config.serverVersion ?? gatewaySnapshot.hello?.server?.version ?? null,
devGitBranch: config.devGitBranch,
updateAvailable: navigationSurfaceHidden ? null : overlaySnapshot.updateAvailable,
updateSchedule: navigationSurfaceHidden ? null : overlaySnapshot.updateSchedule,
heldUpdateCampaignId: overlaySnapshot.heldUpdateCampaignId,
updateBusy,
updateStatusBanner: overlaySnapshot.updateStatusBanner,
watchUpdateProgress,
canUpdate,
canHoldUpdate,
onUpdate: () => void context.overlays.runUpdate(),
refreshRequired: navigationSurfaceHidden ? false : overlaySnapshot.controlUiRefreshRequired,
onRefresh: () => host.refreshControlUi(),
onHoldUpdate: () => context.overlays.holdUpdate(),
onReviewUpdate: () => host.navigate("updates"),
onOpenApprovals: () => host.openApprovals(),
onRetryConnect: () => context.gateway.connect(),
onOpenNewSession: openNewSession,
+1 -3
View File
@@ -41,9 +41,7 @@ export function renderFloatingUpdateCard(params: {
// it still needs the floating copy while navigation is hidden.
const desktopNavigationHidden = params.navigationSurfaceHidden && !params.mobileNavLayout;
const showAttention = desktopNavigationHidden && !params.onboarding && !params.compact;
const showUpdateCard =
!params.compact &&
(params.refreshRequired || (!params.onboarding && params.navigationSurfaceHidden));
const showUpdateCard = !params.compact && params.refreshRequired;
if (!showAttention && !showUpdateCard) {
return nothing;
}
+1
View File
@@ -9,6 +9,7 @@ export type ApplicationOverlaySnapshot = {
heldUpdateCampaignId: string | null;
updateRunning: boolean;
updateStatusRefreshing: boolean;
updateCampaignStatusHydrated: boolean;
updateReconciliationPending: boolean;
updateStatusBanner: ApplicationStatusBanner | null;
recordedUpdateAttempt: RecordedUpdateAttempt | null;
@@ -241,11 +241,59 @@ describe("application update campaign overlays", () => {
await vi.advanceTimersByTimeAsync(10_000);
await flushMicrotasks();
expect(request.mock.calls.filter(([method]) => method === "update.status")).toHaveLength(1);
expect(overlays.snapshot.updateCampaignStatusHydrated).toBe(false);
expect(overlays.snapshot.updateSchedule?.campaign?.id).toBe("campaign-2");
} finally {
overlays.dispose();
}
});
it("holds a campaign surface until its first authoritative status arrives", async () => {
vi.useFakeTimers();
const updateStatus = deferred();
const request = vi.fn<RequestFn>((method) =>
method === "update.status" ? updateStatus.promise : Promise.resolve({}),
);
const harness = createGatewayHarness(client(request));
harness.update({
hello: {
auth: { role: "operator", scopes: ["operator.admin"] },
snapshot: {
updateSchedule: {
channel: "dev",
autoEnabled: true,
campaign: {
id: "campaign-blocked",
state: "waiting-for-idle",
announcedAtMs: 1_000,
forceAtMs: 901_000,
updatedAtMs: 1_000,
},
},
},
} as ApplicationGatewaySnapshot["hello"],
});
const overlays = createApplicationOverlays(harness.gateway);
expect(overlays.snapshot.updateCampaignStatusHydrated).toBe(false);
await vi.advanceTimersByTimeAsync(5_000);
expect(request.mock.calls.filter(([method]) => method === "update.status")).toHaveLength(1);
expect(overlays.snapshot.updateCampaignStatusHydrated).toBe(false);
updateStatus.resolve({
sentinel: {
kind: "update",
status: "error",
stats: { reason: "build-dirty" },
},
});
await flushMicrotasks();
expect(overlays.snapshot.updateCampaignStatusHydrated).toBe(true);
expect(overlays.snapshot.updateStatusBanner?.text).toContain("build-dirty");
overlays.dispose();
});
it("holds an active campaign and adopts the returned schedule", async () => {
const request = vi.fn<RequestFn>(async (method) =>
method === "update.hold"
+15 -29
View File
@@ -3,7 +3,7 @@ import {
type GatewayUpdateAvailableEventPayload,
} from "../../../src/gateway/events.js";
import type { GatewayEventFrame } from "../api/gateway.ts";
import type { UpdateHoldResult, UpdateScheduleState } from "../api/types.ts";
import type { UpdateHoldResult } from "../api/types.ts";
import { controlUiBuildDiffersFrom } from "../build-info.ts";
import { t } from "../i18n/index.ts";
import {
@@ -53,12 +53,12 @@ import {
type UpdateRestartStatusResponse,
type UpdateRunResponse,
} from "./update-overlay-helpers.ts";
import { readUpdateScheduleValue } from "./update-schedule-dto.ts";
import {
readUpdateAvailable,
readUpdateAvailableValue,
readUpdateSchedule,
readUpdateScheduleValue,
} from "./update-schedule-dto.ts";
projectConnectedUpdateSnapshot,
projectUpdateAvailableEvent,
resolveHeldUpdateCampaignId,
} from "./update-schedule-projection.ts";
import {
announceRecordedUpdateSuccess,
announceVerifiedUpdateInstall,
@@ -82,6 +82,7 @@ export function createApplicationOverlays(
heldUpdateCampaignId: null,
updateRunning: false,
updateStatusRefreshing: false,
updateCampaignStatusHydrated: true,
updateReconciliationPending: false,
updateStatusBanner: null,
recordedUpdateAttempt: null,
@@ -184,10 +185,6 @@ export function createApplicationOverlays(
snapshot = { ...snapshot, recordedUpdateAttempt };
publish();
};
const heldCampaignId = (schedule: UpdateScheduleState | null) =>
schedule?.campaign?.holdUntilMs !== undefined
? schedule.campaign.id
: snapshot.heldUpdateCampaignId;
const updateVerification = createUpdateVerificationController({
getPending: () => pendingUpdate,
clearPending: () => {
@@ -214,6 +211,7 @@ export function createApplicationOverlays(
recordedUpdateAttempt: snapshot.recordedUpdateAttempt,
heldUpdateCampaignId: snapshot.heldUpdateCampaignId,
}),
updateCampaignStatusHydrated: true,
};
publish();
};
@@ -327,6 +325,7 @@ export function createApplicationOverlays(
updateSchedule: null,
updateRunning: false,
updateStatusRefreshing: false,
updateCampaignStatusHydrated: true,
};
updateCampaignPoller.stop();
if (next.phase === "reload-required") {
@@ -341,8 +340,6 @@ export function createApplicationOverlays(
publish();
return;
}
const updateSchedule =
connectedSourceChanged || helloChanged ? readUpdateSchedule(next.hello) : undefined;
const serverBuildIdentity = {
version: next.hello?.server?.version,
buildId: next.hello?.server?.buildId,
@@ -352,11 +349,7 @@ export function createApplicationOverlays(
snapshot = {
...snapshot,
...(connectedSourceChanged || helloChanged
? {
updateAvailable: readUpdateAvailable(next.hello),
updateSchedule: updateSchedule ?? null,
heldUpdateCampaignId: heldCampaignId(updateSchedule ?? null),
}
? projectConnectedUpdateSnapshot(snapshot, next.hello)
: {}),
controlUiRefreshRequired: connectedSourceChanged
? (exactBuildIdentityAvailable || connectedEpoch > 0) &&
@@ -408,19 +401,9 @@ export function createApplicationOverlays(
}
if (event.event === GATEWAY_EVENT_UPDATE_AVAILABLE) {
const payload = event.payload as GatewayUpdateAvailableEventPayload | undefined;
const updateSchedule =
payload && Object.hasOwn(payload, "schedule")
? readUpdateScheduleValue(payload.schedule)
: undefined;
snapshot = {
...snapshot,
updateAvailable: readUpdateAvailableValue(payload?.updateAvailable),
...(updateSchedule !== undefined
? {
updateSchedule,
heldUpdateCampaignId: heldCampaignId(updateSchedule),
}
: {}),
...projectUpdateAvailableEvent(snapshot, payload),
};
publish();
updateCampaignPoller.sync();
@@ -580,7 +563,10 @@ export function createApplicationOverlays(
...(updateSchedule !== undefined ? { updateSchedule } : {}),
heldUpdateCampaignId: response.ok
? campaign.id
: heldCampaignId(updateSchedule ?? null),
: resolveHeldUpdateCampaignId(
updateSchedule ?? snapshot.updateSchedule,
snapshot.heldUpdateCampaignId,
),
};
publish();
}
+81
View File
@@ -0,0 +1,81 @@
import type { GatewayHelloOk } from "../api/gateway.ts";
import type { UpdateAvailable, UpdateScheduleState } from "../api/types.ts";
import {
readUpdateAvailable,
readUpdateAvailableValue,
readUpdateSchedule,
readUpdateScheduleValue,
} from "./update-schedule-dto.ts";
type UpdateScheduleProjection = {
updateAvailable: UpdateAvailable | null;
updateSchedule: UpdateScheduleState | null;
heldUpdateCampaignId: string | null;
updateCampaignStatusHydrated: boolean;
};
function retainCampaignStatusHydration(
current: UpdateScheduleState | null,
next: UpdateScheduleState | null | undefined,
hydrated: boolean,
): boolean {
const currentCampaign = current?.campaign;
const nextCampaign = next?.campaign;
return (
!nextCampaign ||
(hydrated &&
currentCampaign?.id === nextCampaign.id &&
currentCampaign.updatedAtMs === nextCampaign.updatedAtMs)
);
}
export function resolveHeldUpdateCampaignId(
schedule: UpdateScheduleState | null,
currentCampaignId: string | null,
): string | null {
return schedule?.campaign?.holdUntilMs !== undefined ? schedule.campaign.id : currentCampaignId;
}
export function projectConnectedUpdateSnapshot(
current: UpdateScheduleProjection,
hello: GatewayHelloOk | null,
): UpdateScheduleProjection {
const updateSchedule = readUpdateSchedule(hello);
return {
updateAvailable: readUpdateAvailable(hello),
updateSchedule,
heldUpdateCampaignId: resolveHeldUpdateCampaignId(updateSchedule, current.heldUpdateCampaignId),
updateCampaignStatusHydrated: retainCampaignStatusHydration(
current.updateSchedule,
updateSchedule,
current.updateCampaignStatusHydrated,
),
};
}
export function projectUpdateAvailableEvent(
current: UpdateScheduleProjection,
payload: { updateAvailable?: unknown; schedule?: unknown } | undefined,
): Partial<UpdateScheduleProjection> {
const updateSchedule =
payload && Object.hasOwn(payload, "schedule")
? readUpdateScheduleValue(payload.schedule)
: undefined;
return {
updateAvailable: readUpdateAvailableValue(payload?.updateAvailable),
...(updateSchedule !== undefined
? {
updateSchedule,
heldUpdateCampaignId: resolveHeldUpdateCampaignId(
updateSchedule,
current.heldUpdateCampaignId,
),
updateCampaignStatusHydrated: retainCampaignStatusHydration(
current.updateSchedule,
updateSchedule,
current.updateCampaignStatusHydrated,
),
}
: {}),
};
}
-14
View File
@@ -1,6 +1,5 @@
import { consume } from "@lit/context";
import { property } from "lit/decorators.js";
import type { UpdateAvailable, UpdateScheduleState } from "../api/types.ts";
import { DEFAULT_SIDEBAR_ENTRIES, type NavigationRouteId } from "../app-navigation.ts";
import type { RouteId } from "../app-route-paths.ts";
import { selectApplicationSession } from "../app/agent-selection.ts";
@@ -12,7 +11,6 @@ import {
import type { CatalogOpenTarget } from "../app/settings.ts";
import type { ThemeMode } from "../app/theme.ts";
import type { UpdateProgress } from "../app/update-confirmation.ts";
import type { ApplicationStatusBanner } from "../app/update-overlay-helpers.ts";
import { readSessionMethodAccess, type SessionMethodAccess } from "../lib/session-method-access.ts";
import { prepareSessionNavigationHandoff } from "../lib/sessions/navigation-handoff.ts";
import { SESSION_NAVIGATION_KEY_PARAM } from "../lib/sessions/route-navigation.ts";
@@ -49,21 +47,9 @@ export abstract class AppSidebarBase extends OpenClawLightDomContentsElement {
@property({ attribute: false }) lobsterPetSounds = false;
@property({ attribute: false }) gatewayVersion: string | null = null;
@property({ attribute: false }) devGitBranch: string | null = null;
@property({ attribute: false }) updateAvailable: UpdateAvailable | null = null;
@property({ attribute: false }) updateSchedule: UpdateScheduleState | null = null;
@property({ attribute: false }) heldUpdateCampaignId: string | null = null;
@property({ attribute: false }) updateBusy = false;
@property({ attribute: false }) updateStatusBanner: ApplicationStatusBanner | null = null;
@property({ attribute: false }) watchUpdateProgress:
| ((listener: (progress: UpdateProgress) => void) => () => void)
| undefined = undefined;
@property({ attribute: false }) canUpdate = false;
@property({ attribute: false }) canHoldUpdate = false;
@property({ attribute: false }) onUpdate: () => void = () => undefined;
@property({ attribute: false }) refreshRequired = false;
@property({ attribute: false }) onRefresh: () => void = () => undefined;
@property({ attribute: false }) onHoldUpdate: () => Promise<boolean> = async () => false;
@property({ attribute: false }) onReviewUpdate: () => void = () => undefined;
@property({ attribute: false }) onOpenApprovals?: () => void;
@property({ attribute: false }) onRetryConnect?: () => void;
@property({ attribute: false }) onOpenNewSession?: (
+2 -43
View File
@@ -7,9 +7,7 @@ import {
} from "../app-navigation.ts";
import { isRouteId, isSessionRouteId, pathForRoute } from "../app-route-paths.ts";
import { resolveControlUiAuthToken } from "../app/control-ui-auth.ts";
import { hasNativeUpdateBridge } from "../app/native-link-routing.ts";
import { isNativeWebChromeHost } from "../app/native-web-chrome.ts";
import { confirmAndStartUpdate } from "../app/update-confirmation.ts";
import { readPresenceEntries, resolveCurrentSelfUser } from "../app/user-profile.ts";
import { CONTROL_UI_BUILD_INFO } from "../build-info.ts";
import { t } from "../i18n/index.ts";
@@ -51,7 +49,6 @@ import { formatSidebarBuildSubtitle } from "./sidebar-build-chip-format.ts";
type AppSidebarRenderHost = AppSidebarSessionNavigationElement & {
activePluginTabId: string;
activeWorkboardBoardId: string;
nativeUpdateDeclined: boolean;
offline: boolean;
getRouteSessionKey(): string;
renderPinnedSidebarSession(session: SidebarRecentSession): unknown;
@@ -370,16 +367,8 @@ export function renderAppSidebarFooterBar(host: AppSidebarRenderHost) {
: gateway
? `${gateway.name}${gatewayPrimaryTag ? `, ${gatewayPrimaryTag}` : ""}`
: buildSubtitle;
const availableUpdate = host.updateAvailable;
const showUpdate = availableUpdate !== null;
const updateBusy = host.updateBusy || host.updateSchedule?.campaign?.state === "applying";
const showInbox = true;
return html`
<div
class="sidebar-footer-bar ${showInbox && showUpdate
? "sidebar-footer-bar--two-actions"
: "sidebar-footer-bar--one-action"}"
>
<div class="sidebar-footer-bar sidebar-footer-bar--one-action">
<button
type="button"
class="sidebar-identity-card"
@@ -422,37 +411,7 @@ export function renderAppSidebarFooterBar(host: AppSidebarRenderHost) {
<span class="sidebar-identity-card__status" role="status" aria-live="polite"
>${host.offline ? t("connection.reconnecting") : ""}</span
>
${showInbox || showUpdate
? html`<span class="sidebar-footer-actions">
${showInbox ? renderAppSidebarAttention(host) : nothing}
${showUpdate
? html`<span class="sidebar-footer-update-slot">
<button
type="button"
class="sidebar-footer-update"
aria-label=${t("updates.sidebar.availableTitle")}
?disabled=${updateBusy || !host.canUpdate}
@click=${() => {
void confirmAndStartUpdate({
updateAvailable: availableUpdate,
updateSchedule: host.updateSchedule,
viaNativeApp: !host.nativeUpdateDeclined && hasNativeUpdateBridge(),
startGatewayUpdate: host.onUpdate,
...(host.watchUpdateProgress
? { watchUpdateProgress: host.watchUpdateProgress }
: {}),
});
}}
>
<span class="sidebar-footer-update__icon" aria-hidden="true"
>${icons.download}</span
>
<span class="sidebar-footer-update__label">${t("updates.sidebar.action")}</span>
</button>
</span>`
: nothing}
</span>`
: nothing}
<span class="sidebar-footer-actions">${renderAppSidebarAttention(host)}</span>
</div>
`;
}
-38
View File
@@ -7,10 +7,6 @@ import type {
} from "../../../packages/gateway-protocol/src/index.js";
import type { SessionObserverDigest } from "../../../packages/gateway-protocol/src/schema/sessions.js";
import { isSessionRouteId, pathForRoute } from "../app-route-paths.ts";
import {
NATIVE_UPDATE_AVAILABILITY_CHANGED_EVENT,
NATIVE_UPDATE_DECLINED_EVENT,
} from "../app/native-link-routing.ts";
import { beginNativeWindowDragFromTopInset } from "../app/native-window-drag.ts";
import { t } from "../i18n/index.ts";
import { BoardAvailabilityController } from "../lib/board/availability-controller.ts";
@@ -81,7 +77,6 @@ const sidebarChromeImport = createIdleImport(() =>
class AppSidebar extends AppSidebarSessionNavigationElement implements SessionListHost {
@state() sidebarNarrationLines: ReadonlyMap<string, string> = new Map();
@state() sidebarObserverDigests: ReadonlyMap<string, SessionObserverDigest> = new Map();
@state() nativeUpdateDeclined = false;
override readonly sessionOrganizer = new SessionOrganizerController(this);
override readonly sidebarMenus = new SidebarMenusController(this);
@@ -216,11 +211,6 @@ class AppSidebar extends AppSidebarSessionNavigationElement implements SessionLi
override disconnectedCallback() {
window.removeEventListener("openclaw:native-gateways-changed", this.nativeGatewaysChanged);
window.removeEventListener(
NATIVE_UPDATE_AVAILABILITY_CHANGED_EVENT,
this.handleNativeUpdateAvailabilityChanged,
);
window.removeEventListener(NATIVE_UPDATE_DECLINED_EVENT, this.handleNativeUpdateDeclined);
window.removeEventListener(
SIDEBAR_HIDDEN_SESSION_CATALOGS_CHANGED_EVENT,
this.hiddenSessionCatalogsChanged,
@@ -325,13 +315,7 @@ class AppSidebar extends AppSidebarSessionNavigationElement implements SessionLi
override connectedCallback() {
super.connectedCallback();
this.nativeUpdateDeclined = false;
window.addEventListener("openclaw:native-gateways-changed", this.nativeGatewaysChanged);
window.addEventListener(
NATIVE_UPDATE_AVAILABILITY_CHANGED_EVENT,
this.handleNativeUpdateAvailabilityChanged,
);
window.addEventListener(NATIVE_UPDATE_DECLINED_EVENT, this.handleNativeUpdateDeclined);
this.hiddenSessionCatalogsChanged();
window.addEventListener(
SIDEBAR_HIDDEN_SESSION_CATALOGS_CHANGED_EVENT,
@@ -343,28 +327,6 @@ class AppSidebar extends AppSidebarSessionNavigationElement implements SessionLi
this.catalogRendererImport.schedule();
}
private readonly handleNativeUpdateDeclined = () => {
if (this.nativeUpdateDeclined) {
return;
}
this.nativeUpdateDeclined = true;
const campaign = this.updateSchedule?.campaign;
const updateBusy = this.updateBusy || campaign?.state === "applying";
if (
(this.updateAvailable || campaign) &&
!updateBusy &&
this.canUpdate &&
!this.refreshRequired
) {
this.onUpdate();
}
};
private readonly handleNativeUpdateAvailabilityChanged = () => {
this.nativeUpdateDeclined = false;
this.requestUpdate();
};
protected override firstUpdated() {
requestAnimationFrame(() => requestAnimationFrame(() => this.classList.add("sidebar-r")));
}
@@ -0,0 +1,77 @@
import { afterEach, describe, expect, it } from "vitest";
import "../test-helpers/load-styles.ts";
import "../styles/sidebar-issues.css";
import "./web-awesome-tabs.ts";
afterEach(() => document.body.replaceChildren());
describe.runIf("__vitest_browser__" in globalThis)("Inbox panel layout", () => {
it("keeps tabs compact and item rails flush with the scrollport", async () => {
const fixture = document.createElement("section");
fixture.className = "sidebar-issues-panel";
fixture.style.position = "static";
fixture.style.width = "390px";
fixture.style.height = "220px";
fixture.innerHTML = `
<wa-tab-group class="sidebar-issues-panel__tabs" without-scroll-controls>
${["All", "Approvals", "Automations", "System"]
.map(
(label, index) => `<wa-tab
slot="nav"
class="sidebar-issues-panel__tab"
panel="tab-${index}"
${index === 0 ? "active" : ""}
><span>${label}</span><span class="sidebar-issues-panel__tab-count">${index}</span></wa-tab>`,
)
.join("")}
</wa-tab-group>
<div class="sidebar-issues-panel__list-wrap">
<div class="sidebar-issues-panel__list">
${Array.from(
{ length: 6 },
(_, index) => `<div data-attention-kind="cronFailed">
<div class="sidebar-issues-panel__summary">Inbox item ${index}</div>
</div>`,
).join("")}
</div>
</div>
`;
document.body.append(fixture);
await customElements.whenDefined("wa-tab-group");
const group = fixture.querySelector<HTMLElement & { updateComplete: Promise<unknown> }>(
".sidebar-issues-panel__tabs",
);
const header = document.createElement("header");
header.className = "sidebar-issues-panel__header";
fixture.prepend(header);
const tab = fixture.querySelector<HTMLElement & { updateComplete: Promise<unknown> }>(
".sidebar-issues-panel__tab",
);
expect(group).not.toBeNull();
expect(tab).not.toBeNull();
await group?.updateComplete;
await tab?.updateComplete;
const base = tab?.shadowRoot?.querySelector<HTMLElement>("[part='base']");
const label = tab?.querySelector<HTMLElement>("span:first-child");
const count = tab?.querySelector<HTMLElement>(".sidebar-issues-panel__tab-count");
const list = fixture.querySelector<HTMLElement>(".sidebar-issues-panel__list");
const item = fixture.querySelector<HTMLElement>("[data-attention-kind]");
const summary = fixture.querySelector<HTMLElement>(".sidebar-issues-panel__summary");
expect(group?.scrollWidth).toBe(group?.clientWidth);
expect(getComputedStyle(group!).overflowX).toBe("hidden");
expect(getComputedStyle(group!).backgroundColor).toBe(getComputedStyle(header).backgroundColor);
expect(getComputedStyle(group!).backgroundColor).not.toBe(
getComputedStyle(list!).backgroundColor,
);
expect(getComputedStyle(group!).borderBottomWidth).toBe("1px");
expect(getComputedStyle(base!).padding).toBe("4px 8px");
expect(
count!.getBoundingClientRect().left - label!.getBoundingClientRect().right,
).toBeGreaterThan(4);
expect(getComputedStyle(summary!).paddingBlock).toBe("8px");
expect(item!.getBoundingClientRect().right).toBeCloseTo(list!.getBoundingClientRect().right, 1);
});
});
@@ -53,8 +53,10 @@ function cronListResponse(jobs: CronJob[]): CronJobsListResult {
}
type SidebarAttentionElement = HTMLElement & {
context: ApplicationContext;
updateComplete: Promise<boolean>;
cronJobs: CronJob[];
hasUpdateSurface(): boolean;
modelAuthStatus: ModelAuthStatusResult | null;
loadedAtMs: number;
};
@@ -477,6 +479,55 @@ describe("sidebar attention refresh ownership", () => {
});
});
describe("update attention", () => {
it("hides an unhydrated campaign only while update status can be polled", () => {
const overlaySnapshot = {
updateAvailable: {
currentVersion: "2026.8.1",
latestVersion: "2026.8.1",
channel: "dev",
commitsBehind: 2,
},
updateSchedule: {
channel: "dev",
autoEnabled: true,
campaign: {
id: "campaign-1",
state: "waiting-for-idle",
announcedAtMs: 1_000,
forceAtMs: 901_000,
updatedAtMs: 1_000,
},
},
updateCampaignStatusHydrated: false,
updateRunning: false,
updateStatusBanner: null,
};
const gatewaySnapshot = {
client: {} as GatewayBrowserClient,
phase: "connected" as const,
hello: {
auth: { role: "operator", scopes: ["operator.admin"] },
features: { methods: ["update.status"] },
},
};
const element = document.createElement("openclaw-sidebar-attention") as SidebarAttentionElement;
element.context = {
gateway: { snapshot: gatewaySnapshot },
overlays: { snapshot: overlaySnapshot },
} as unknown as ApplicationContext;
expect(element.hasUpdateSurface()).toBe(false);
gatewaySnapshot.hello.auth.scopes = ["operator.read"];
expect(element.hasUpdateSurface()).toBe(true);
gatewaySnapshot.hello.auth.scopes = ["operator.admin"];
overlaySnapshot.updateCampaignStatusHydrated = true;
expect(element.hasUpdateSurface()).toBe(true);
});
});
describe("pruneDismissals", () => {
const chip = (kind: SidebarAttentionKind, signature: string) => ({ kind, signature });
+55 -1
View File
@@ -8,6 +8,10 @@ import type { CronJob, ModelAuthStatusResult } from "../api/types.ts";
import type { NavigationRouteId } from "../app-navigation.ts";
import { applicationContext, type ApplicationContext } from "../app/context.ts";
import type { ExecApprovalDecision, ExecApprovalRequest } from "../app/exec-approval.ts";
import {
NATIVE_UPDATE_AVAILABILITY_CHANGED_EVENT,
NATIVE_UPDATE_DECLINED_EVENT,
} from "../app/native-link-routing.ts";
import type { UpdateProgress } from "../app/update-confirmation.ts";
import { t } from "../i18n/index.ts";
import { createInitialCronState, loadCronJobsPage } from "../lib/cron/index.ts";
@@ -75,6 +79,7 @@ class SidebarAttention extends OpenClawLightDomContentsElement {
private panelTrigger: HTMLElement | null = null;
private panelRenderer: SidebarAttentionPanelRenderer | null = null;
private panelLoad: Promise<SidebarAttentionPanelRenderer> | null = null;
private nativeUpdateDeclined = false;
private readonly loadTask = new Task(this, {
autoRun: false,
@@ -197,14 +202,25 @@ class SidebarAttention extends OpenClawLightDomContentsElement {
override connectedCallback() {
super.connectedCallback();
this.nativeUpdateDeclined = false;
document.addEventListener("visibilitychange", this.refreshIfStale);
globalThis.addEventListener("storage", this.syncDismissalsFromStorage);
window.addEventListener(
NATIVE_UPDATE_AVAILABILITY_CHANGED_EVENT,
this.handleNativeUpdateAvailabilityChanged,
);
window.addEventListener(NATIVE_UPDATE_DECLINED_EVENT, this.handleNativeUpdateDeclined);
this.idleRefreshTimer = globalThis.setInterval(this.refreshIfStale, IDLE_REFRESH_INTERVAL_MS);
}
override disconnectedCallback() {
document.removeEventListener("visibilitychange", this.refreshIfStale);
globalThis.removeEventListener("storage", this.syncDismissalsFromStorage);
window.removeEventListener(
NATIVE_UPDATE_AVAILABILITY_CHANGED_EVENT,
this.handleNativeUpdateAvailabilityChanged,
);
window.removeEventListener(NATIVE_UPDATE_DECLINED_EVENT, this.handleNativeUpdateDeclined);
document.removeEventListener("pointerdown", this.closeOnOutsidePointer, true);
if (this.idleRefreshTimer !== null) {
globalThis.clearInterval(this.idleRefreshTimer);
@@ -219,6 +235,32 @@ class SidebarAttention extends OpenClawLightDomContentsElement {
super.disconnectedCallback();
}
private readonly handleNativeUpdateAvailabilityChanged = () => {
this.nativeUpdateDeclined = false;
this.requestUpdate();
};
// This element outlives the lazy panel, so a confirmed native handoff can
// always continue through the Gateway when the host declines it.
private readonly handleNativeUpdateDeclined = () => {
if (this.nativeUpdateDeclined) {
return;
}
this.nativeUpdateDeclined = true;
const snapshot = this.context?.overlays.snapshot;
const campaign = snapshot?.updateSchedule?.campaign;
const busy = snapshot?.updateRunning || campaign?.state === "applying";
if (
snapshot &&
(snapshot.updateAvailable || campaign) &&
!busy &&
!snapshot.controlUiRefreshRequired &&
canCallGatewayMethod(this.context?.gateway.snapshot, "update.run", "operator.admin")
) {
void this.context?.overlays.runUpdate();
}
};
protected override willUpdate(changed: PropertyValues<this>) {
if (changed.has("activeRouteId") && changed.get("activeRouteId") !== undefined) {
this.closePanel(false);
@@ -318,8 +360,20 @@ class SidebarAttention extends OpenClawLightDomContentsElement {
private hasUpdateSurface(): boolean {
const snapshot = this.context?.overlays.snapshot;
if (!snapshot) {
return false;
}
const campaign = snapshot.updateSchedule?.campaign;
const canHydrateCampaign = canCallGatewayMethod(
this.context?.gateway.snapshot,
"update.status",
"operator.admin",
);
if (campaign && !snapshot.updateCampaignStatusHydrated && canHydrateCampaign) {
return Boolean(snapshot.updateRunning || snapshot.updateStatusBanner);
}
return Boolean(
snapshot?.updateRunning || snapshot?.updateStatusBanner || snapshot?.updateSchedule?.campaign,
snapshot.updateRunning || snapshot.updateStatusBanner || snapshot.updateAvailable || campaign,
);
}
+22 -2
View File
@@ -9,6 +9,7 @@ import "./sidebar-update-card.ts";
type SidebarUpdateCardElement = HTMLElement & {
updateAvailable: UpdateAvailable | null;
updateSchedule: UpdateScheduleState | null;
compact: boolean;
heldUpdateCampaignId: string | null;
updateBusy: boolean;
canUpdate: boolean;
@@ -138,7 +139,7 @@ describe("SidebarUpdateCard", () => {
expect(onUpdate).toHaveBeenCalledOnce();
});
it("narrates the whole update after the Gateway drops its metadata", async () => {
it("renders an available update and narrates it after the Gateway drops its metadata", async () => {
const element = await mount(
{ currentVersion: "1.0.0", latestVersion: "1.0.0", channel: "dev", commitsBehind: 246 },
{
@@ -152,7 +153,9 @@ describe("SidebarUpdateCard", () => {
},
},
);
expect(element.querySelector(".sidebar-update-card")).toBeNull();
expect(element.querySelector(".sidebar-update-card__action")?.textContent).toContain(
"246 commits behind",
);
element.updateBusy = true;
await element.updateComplete;
@@ -166,6 +169,23 @@ describe("SidebarUpdateCard", () => {
expect(element.textContent).toContain("Updating Gateway…");
});
it("keeps an available update actionable inside the compact Inbox row", async () => {
const element = await mount({
currentVersion: "1.0.0",
latestVersion: "2.0.0",
channel: "stable",
});
element.compact = true;
await element.updateComplete;
expect(element.querySelector(".sidebar-issues-panel__entity")?.textContent).toBe(
"Update available",
);
expect(element.querySelector(".sidebar-update-card__action")?.textContent).toContain(
"Update Gateway",
);
});
it("renders a quiet live countdown and stops ticking on disconnect", async () => {
vi.useFakeTimers();
vi.setSystemTime(1_000);
+13 -3
View File
@@ -139,6 +139,16 @@ class SidebarUpdateCard extends OpenClawLightDomContentsElement {
this.holdingCampaignId = null;
};
private hasAvailableUpdate() {
const update = this.updateAvailable;
const gitTarget = this.updateSchedule?.target;
return (
(update !== null && update.latestVersion !== update.currentVersion) ||
(update?.commitsBehind !== undefined && update.commitsBehind > 0) ||
(gitTarget?.kind === "git" && gitTarget.commitsBehind > 0)
);
}
private compactSummary() {
if (this.refreshRequired) {
return {
@@ -151,7 +161,7 @@ class SidebarUpdateCard extends OpenClawLightDomContentsElement {
const campaign = this.updateSchedule?.campaign;
const busy = this.updateBusy || campaign?.state === "applying";
const statusBanner = this.statusBanner;
if (!campaign && !busy && !statusBanner) {
if (!campaign && !busy && !statusBanner && !this.hasAvailableUpdate()) {
return null;
}
const targetLabel = formatUpdateTargetLabel(this.updateSchedule, this.updateAvailable);
@@ -298,7 +308,7 @@ class SidebarUpdateCard extends OpenClawLightDomContentsElement {
// metadata while it restarts, and the card must not vanish or fall back to
// the stale "update available" call to action mid-install.
const statusBanner = this.statusBanner;
if (!campaign && !busy && !statusBanner) {
if (!campaign && !busy && !statusBanner && !this.hasAvailableUpdate()) {
return nothing;
}
const title = this.nativeUpdateAvailable
@@ -330,7 +340,7 @@ class SidebarUpdateCard extends OpenClawLightDomContentsElement {
);
// An outcome with nothing left to act on is the whole card: re-offering an
// update the operator just ran would bury the reason it failed.
const actionable = Boolean(campaign || busy || (update && (hasVersionUpdate || hasGitUpdate)));
const actionable = Boolean(campaign || busy || hasVersionUpdate || hasGitUpdate);
return html`
<div
class="sidebar-update-card"
+6 -3
View File
@@ -844,10 +844,13 @@ suite.define(() => {
});
const sidebar = page.locator("openclaw-app-sidebar");
const sidebarUpdate = sidebar.locator(".sidebar-footer-update");
const sidebarUpdate = sidebar.locator(
'openclaw-sidebar-update-card[data-attention-kind="updateAvailable"]',
);
const sidebarAutomation = sidebar.locator('[data-attention-kind="cronFailed"]');
await expect.poll(() => sidebarUpdate.count()).toBe(1);
await expect.poll(() => sidebar.locator(".sidebar-footer-update").count()).toBe(0);
await sidebar.locator(".sidebar-issues-button").click();
await expect.poll(() => sidebarUpdate.count()).toBe(1);
await expect.poll(() => sidebarAutomation.count()).toBe(1);
await captureUiProof(page, "08-desktop-attention-unchanged.png");
await sidebar.locator(".sidebar-issues-button").click();
@@ -873,8 +876,8 @@ suite.define(() => {
await expect
.poll(() => page.locator(".shell").getAttribute("class"))
.toContain("shell--nav-drawer-open");
await expect.poll(() => sidebarUpdate.isVisible()).toBe(true);
await sidebar.locator(".sidebar-issues-button").click();
await expect.poll(() => sidebarUpdate.isVisible()).toBe(true);
await expect.poll(() => sidebarAutomation.isVisible()).toBe(true);
await captureUiProof(page, "10-mobile-attention-drawer.png");
+12 -3
View File
@@ -18,7 +18,12 @@ const MANAGED_UPDATE_HANDOFF_RESPONSE = {
} as const;
async function openUpdateConfirmation(page: Page): Promise<void> {
await page.locator(".sidebar-footer-update:visible").click();
await page.locator(".sidebar-issues-button").click();
const updateIssue = page.locator(
'openclaw-sidebar-update-card[data-attention-kind="updateAvailable"]',
);
await updateIssue.locator("summary").click();
await updateIssue.locator(".sidebar-update-card__action").click();
}
suite.define(() => {
@@ -74,7 +79,7 @@ suite.define(() => {
);
await updateIssue.locator("summary").click();
await updateIssue.locator(".sidebar-update-card__compact-reason").waitFor();
expect(await page.locator(".sidebar-footer-update:visible").isEnabled()).toBe(true);
expect(await page.locator(".sidebar-footer-update").count()).toBe(0);
expect(pageErrors).toEqual([]);
await page.screenshot({ path: path.join(artifactDir, "package-update-failure.png") });
},
@@ -132,7 +137,7 @@ suite.define(() => {
{ exact: true },
)
.waitFor();
expect(await page.locator(".sidebar-footer-update:visible").isEnabled()).toBe(false);
expect(await page.locator(".sidebar-footer-update").count()).toBe(0);
expect(pageErrors).toEqual([]);
await page.screenshot({ path: path.join(artifactDir, "coalesced-restart-banner.png") });
},
@@ -288,6 +293,10 @@ suite.define(() => {
).toEqual([{ type: "start-update" }]);
expect(await gateway.getRequests("update.run")).toHaveLength(0);
// The confirmation closes the lazy Inbox. Recovery belongs to the
// persistent attention owner, not the now-disconnected update card.
await page.keyboard.press("Escape");
await expect.poll(() => page.locator(".sidebar-issues-panel").count()).toBe(0);
await page.evaluate(
(eventName) => window.dispatchEvent(new CustomEvent(eventName)),
NATIVE_UPDATE_DECLINED_EVENT,
+17 -8
View File
@@ -27,6 +27,10 @@ function confirmationCopy(page: Page) {
return page.locator("openclaw-modal-dialog");
}
function confirmationDialog(page: Page) {
return page.getByRole("dialog", { name: "Update Gateway", exact: true });
}
async function openUpdateCard(page: Page, baseUrl: string, compact = false) {
const gateway = await installMockGateway(page, {
methodResponses: { "update.run": UPDATE_RUN_RESPONSE },
@@ -40,7 +44,12 @@ async function openUpdateCard(page: Page, baseUrl: string, compact = false) {
await updateButton.waitFor({ timeout: 10_000 });
return { compact, gateway, updateButton };
}
const updateButton = page.locator(".sidebar-footer-update:visible");
await page.locator(".sidebar-issues-button").click();
const updateIssue = page.locator(
'openclaw-sidebar-update-card[data-attention-kind="updateAvailable"]',
);
await updateIssue.locator("summary").click();
const updateButton = updateIssue.locator(".sidebar-update-card__action");
await updateButton.waitFor({ timeout: 10_000 });
return { compact, gateway, updateButton };
}
@@ -65,7 +74,7 @@ suite.define(() => {
await openConfirmation(page, updateButton);
const dialog = page.getByRole("dialog");
const dialog = confirmationDialog(page);
await dialog.waitFor();
expect(await dialog.getAttribute("aria-label")).toBe("Update Gateway");
const dialogText = await confirmationCopy(page).textContent();
@@ -108,7 +117,7 @@ suite.define(() => {
compact,
);
await openConfirmation(page, updateButton, compact);
await page.getByRole("dialog").waitFor();
await confirmationDialog(page).waitFor();
expect(
await confirmationCopy(page)
.getByRole("button", { name: "Update and restart", exact: true })
@@ -133,14 +142,14 @@ suite.define(() => {
async ({ page }) => {
const { gateway, updateButton } = await openUpdateCard(page, suite.server.baseUrl);
await openConfirmation(page, updateButton);
await page.getByRole("dialog").waitFor();
await confirmationDialog(page).waitFor();
if (dismiss === "Escape") {
await page.keyboard.press("Escape");
} else {
await page.getByRole("button", { name: "Cancel", exact: true }).click();
}
await page.getByRole("dialog").waitFor({ state: "detached" });
await confirmationDialog(page).waitFor({ state: "detached" });
expect(await gateway.getRequests("update.run")).toHaveLength(0);
},
@@ -155,7 +164,7 @@ suite.define(() => {
await updateButton.focus();
await page.keyboard.press("Enter");
const dialog = page.getByRole("dialog");
const dialog = confirmationDialog(page);
await dialog.waitFor();
// The keypress that opened the dialog lands on Cancel, never on the confirm action.
@@ -192,7 +201,7 @@ suite.define(() => {
async ({ page }) => {
const { gateway, updateButton } = await openUpdateCard(page, suite.server.baseUrl);
await openConfirmation(page, updateButton);
await page.getByRole("dialog").waitFor();
await confirmationDialog(page).waitFor();
await confirmationCopy(page)
.getByRole("button", { name: "Update and restart", exact: true })
@@ -236,7 +245,7 @@ suite.define(() => {
await gateway.emitGatewayEvent("update.available", { updateAvailable: UPDATE_AVAILABLE });
await page.getByRole("button", { name: "Update now", exact: true }).click();
const dialog = page.getByRole("dialog");
const dialog = confirmationDialog(page);
await dialog.waitFor();
expect(await confirmationCopy(page).textContent()).toContain(
"Installed v1.0.0 · Available v2.0.0",
+6 -1
View File
@@ -50,7 +50,12 @@ const HANDOFF_PENDING_SENTINEL = {
};
async function openUpdateConfirmation(page: Page): Promise<void> {
await page.locator(".sidebar-footer-update:visible").click();
await page.locator(".sidebar-issues-button").click();
const updateIssue = page.locator(
'openclaw-sidebar-update-card[data-attention-kind="updateAvailable"]',
);
await updateIssue.locator("summary").click();
await updateIssue.locator(".sidebar-update-card__action").click();
}
suite.define(() => {
-116
View File
@@ -3288,10 +3288,6 @@ wa-dropdown-item.session-menu__item::part(submenu-icon) {
padding-inline-end: 44px;
}
.sidebar-footer-bar--two-actions {
padding-inline-end: 88px;
}
.sidebar-footer-actions {
position: absolute;
inset-block-start: 50%;
@@ -3299,7 +3295,6 @@ wa-dropdown-item.session-menu__item::part(submenu-icon) {
display: flex;
height: 32px;
align-items: center;
gap: 12px;
transform: translateY(-50%);
}
@@ -3471,117 +3466,6 @@ openclaw-sidebar-attention {
border: 0;
}
.sidebar-footer-update-slot {
position: relative;
width: 32px;
height: 32px;
flex: 0 0 32px;
transition:
width var(--duration-fast) ease,
flex-basis var(--duration-fast) ease;
}
.sidebar-footer-update-slot:has(.sidebar-footer-update:hover:not(:disabled)) {
width: 76px;
flex-basis: 76px;
transition-delay: var(--duration-slow);
}
.sidebar-footer-update-slot:focus-within {
width: 76px;
flex-basis: 76px;
}
.sidebar-footer-update-slot::before {
content: "";
position: absolute;
z-index: 1;
inset-block: 0;
inset-inline-end: 20px;
width: 64px;
pointer-events: none;
background: linear-gradient(to right, transparent, var(--sidebar-bg));
opacity: 0;
transition: opacity var(--duration-fast) ease;
}
.sidebar-footer-update-slot:has(.sidebar-footer-update:hover:not(:disabled))::before {
opacity: 1;
transition-delay: var(--duration-slow);
}
.sidebar-footer-update-slot:focus-within::before {
opacity: 1;
}
.sidebar-footer-update {
position: absolute;
z-index: 2;
inset-block-start: 2px;
inset-inline-end: 2px;
display: inline-flex;
width: 28px;
height: 28px;
align-items: center;
justify-content: flex-start;
gap: 5px;
overflow: hidden;
padding: 0 7px;
border: 0;
border-radius: var(--radius-full);
background: var(--inbox-attention);
color: var(--inbox-attention-foreground);
cursor: var(--cursor-action);
transition:
width var(--duration-fast) ease,
background var(--duration-fast) ease;
}
.sidebar-footer-update:hover:not(:disabled) {
width: 76px;
background: var(--inbox-attention-hover);
transition-delay: var(--duration-slow);
}
.sidebar-footer-update:focus-visible {
width: 76px;
background: var(--inbox-attention-hover);
outline: none;
box-shadow: var(--focus-ring);
}
.sidebar-footer-update:disabled {
cursor: not-allowed;
opacity: 0.56;
}
.sidebar-footer-update__icon {
display: inline-flex;
flex: 0 0 14px;
}
.sidebar-footer-update__icon svg {
width: 14px;
height: 14px;
}
.sidebar-footer-update__label {
opacity: 0;
font-size: var(--control-ui-text-xs);
font-weight: 600;
white-space: nowrap;
transition: opacity var(--duration-fast) ease;
}
.sidebar-footer-update:hover:not(:disabled) .sidebar-footer-update__label {
opacity: 1;
transition-delay: var(--duration-slow);
}
.sidebar-footer-update:focus-visible .sidebar-footer-update__label {
opacity: 1;
}
openclaw-tooltip.sidebar-hover-tooltip {
--openclaw-tooltip-max-width: min(340px, calc(100vw - 24px));
--openclaw-tooltip-open-animation: openclaw-tooltip-hover-card-in 100ms var(--ease-out);
+31 -17
View File
@@ -82,6 +82,7 @@
justify-content: space-between;
gap: 8px;
padding: 8px 12px;
background: var(--secondary);
}
.sidebar-issues-panel__heading {
@@ -147,25 +148,29 @@
}
.sidebar-issues-panel__tabs {
display: flex;
display: block;
flex: none;
overflow: hidden;
border-block-end: 1px solid var(--border);
background: var(--secondary);
--indicator-color: transparent;
--track-color: transparent;
--track-width: 0;
}
.sidebar-issues-panel__tabs::part(tabs) {
box-sizing: border-box;
width: 100%;
gap: 2px;
overflow-x: auto;
padding: 0 6px 8px;
border-block-end: 1px solid var(--border-strong);
scrollbar-width: thin;
}
.sidebar-issues-panel__tab {
display: inline-flex;
min-height: 32px;
flex: 1 0 auto;
align-items: center;
justify-content: center;
gap: 5px;
padding: 4px 8px;
display: block;
min-width: 0;
flex: 1 1 0;
padding: 0;
border: 0;
border-radius: var(--radius-md);
background: transparent;
color: var(--muted);
cursor: var(--cursor-action);
@@ -175,17 +180,27 @@
white-space: nowrap;
}
.sidebar-issues-panel__tab:hover {
.sidebar-issues-panel__tab::part(base) {
box-sizing: border-box;
width: 100%;
min-height: 32px;
justify-content: center;
gap: 5px;
padding: 4px 8px;
border-radius: var(--radius-md);
}
.sidebar-issues-panel__tab:hover::part(base) {
background: var(--bg-hover);
color: var(--text);
}
.sidebar-issues-panel__tab[aria-selected="true"] {
.sidebar-issues-panel__tab:is([active], [aria-selected="true"])::part(base) {
background: color-mix(in srgb, var(--bg-hover) 92%, var(--text) 8%);
color: var(--text-strong);
}
.sidebar-issues-panel__tab:focus-visible {
.sidebar-issues-panel__tab:focus-visible::part(base) {
outline: none;
box-shadow: var(--focus-ring);
}
@@ -213,7 +228,6 @@
padding: 6px;
overscroll-behavior: contain;
scroll-padding-block: 6px 18px;
scrollbar-gutter: stable;
scrollbar-width: thin;
touch-action: pan-y;
}
@@ -321,7 +335,7 @@
min-height: 40px;
align-items: center;
gap: 8px;
padding: 7px 12px 4px;
padding: 8px 12px;
border-radius: 0;
cursor: var(--cursor-action);
list-style: none;