mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 04:47:03 -06:00
feat(ui): let admins dismiss the sidebar update indicator (#128365)
* feat(ui): let admins dismiss the sidebar update indicator Refs #128232 * refactor(ui): clean up update dismissal flow * fix(ui): align update action state * fix(ui): keep update reconciliation non-actionable * fix(ui): preserve update reconciliation visibility * fix: validate update dismissal facts * fix(ui): resurface active updates * fix(ui): show applying update campaigns * fix(gateway): preserve boot identity * perf(ui): split sidebar update styles * fix(test): provide captured plugin runtime
This commit is contained in:
committed by
GitHub
parent
bb6e4b1dee
commit
6669872a95
@@ -3548,7 +3548,7 @@ src/plugins/bundled-channel-config-metadata.ts 4
|
||||
src/plugins/bundled-plugin-scan.ts 1
|
||||
src/plugins/candidate-install-owner.ts 2
|
||||
src/plugins/capability-provider-runtime.ts 16
|
||||
src/plugins/captured-registration.ts 3
|
||||
src/plugins/captured-registration.ts 2
|
||||
src/plugins/channel-presence-policy.ts 2
|
||||
src/plugins/channel-validation.ts 1
|
||||
src/plugins/clawhub.ts 10
|
||||
@@ -4063,7 +4063,6 @@ ui/src/components/session-menu.ts 1
|
||||
ui/src/components/session-organizer-controller.ts 5
|
||||
ui/src/components/settings-sidebar.ts 3
|
||||
ui/src/components/settings-ui.ts 7
|
||||
ui/src/components/sidebar-attention-dismissals.ts 1
|
||||
ui/src/components/sidebar-attention.ts 4
|
||||
ui/src/components/terminal/terminal-connection.ts 2
|
||||
ui/src/components/terminal/terminal-controller-lifecycle.ts 1
|
||||
|
||||
@@ -90,6 +90,7 @@ export const HelloOkSchema = closedObject({
|
||||
server: closedObject({
|
||||
version: NonEmptyString,
|
||||
buildId: Type.Optional(Type.String({ minLength: 1, maxLength: 96 })),
|
||||
bootId: Type.Optional(Type.String({ minLength: 1, maxLength: 96 })),
|
||||
controlUiBuildSource: Type.Optional(
|
||||
Type.Union([Type.Literal("bundled"), Type.Literal("configured")]),
|
||||
),
|
||||
|
||||
@@ -90,7 +90,6 @@ const OBSERVER_DEMO_RUN_ID = "mock-session-observer-run";
|
||||
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;
|
||||
@@ -1947,6 +1946,7 @@ async function createChatPickerScenario(
|
||||
assistantAgentId: "main",
|
||||
assistantName: "Molty",
|
||||
defaultAgentId: "main",
|
||||
gatewayBootId: "mock-gateway-boot-1",
|
||||
serverBuildId: "mock",
|
||||
updateSchedule,
|
||||
updateAvailable: updateFixture?.available ?? null,
|
||||
|
||||
@@ -1194,6 +1194,7 @@ async function runGatewayCommandOnce(opts: GatewayRunOpts, hooks: GatewayRunRunt
|
||||
startupConfigSnapshotReadForNextStart = undefined;
|
||||
return await startGatewayServer(port, {
|
||||
bind,
|
||||
...(activeBootId ? { bootId: activeBootId } : {}),
|
||||
auth: authOverride,
|
||||
tailscale: tailscaleOverride,
|
||||
startupStartedAt,
|
||||
|
||||
@@ -22,6 +22,8 @@ export type GatewayServer = {
|
||||
};
|
||||
|
||||
export type GatewayServerOptions = {
|
||||
/** Exact lifecycle generation projected to connected clients. */
|
||||
bootId?: string;
|
||||
/**
|
||||
* Bind address policy for the Gateway WebSocket/HTTP server.
|
||||
* - loopback: 127.0.0.1
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { startGatewayServerCore } from "./server-start.js";
|
||||
|
||||
describe("Gateway boot ID", () => {
|
||||
it.each(["", " ", " boot-a", "boot-a ", "x".repeat(97)])(
|
||||
"rejects an invalid public boot ID",
|
||||
async (bootId) => {
|
||||
await expect(startGatewayServerCore(0, { bootId })).rejects.toThrow(
|
||||
"Gateway boot ID must contain 1 to 96 characters",
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { formatErrorMessage } from "../infra/errors.js";
|
||||
import { createLazyRuntimeModule } from "../shared/lazy-runtime.js";
|
||||
import {
|
||||
@@ -24,6 +25,16 @@ export async function startGatewayServerCore(
|
||||
port = 18789,
|
||||
opts: GatewayServerOptions = {},
|
||||
): Promise<GatewayServer> {
|
||||
// Direct embedders have no CLI lifecycle row, so the server start boundary
|
||||
// still owns an exact generation instead of making clients infer one.
|
||||
const suppliedBootId = opts.bootId;
|
||||
if (
|
||||
suppliedBootId !== undefined &&
|
||||
(suppliedBootId.trim() !== suppliedBootId || !suppliedBootId || suppliedBootId.length > 96)
|
||||
) {
|
||||
throw new Error("Gateway boot ID must contain 1 to 96 characters");
|
||||
}
|
||||
const bootId = suppliedBootId ?? randomUUID();
|
||||
let releasePostReadyWork: () => void = () => {};
|
||||
const postReadyWorkBarrier = new Promise<void>((resolve) => {
|
||||
releasePostReadyWork = resolve;
|
||||
@@ -68,6 +79,7 @@ export async function startGatewayServerCore(
|
||||
kernelRuntime: { ...gatewayKernel, ...transport },
|
||||
port,
|
||||
opts,
|
||||
bootId,
|
||||
log,
|
||||
logHealth,
|
||||
logWsControl,
|
||||
|
||||
@@ -26,6 +26,7 @@ type GatewayStartedRuntime = GatewayKernelRuntime & GatewayHttpTransport;
|
||||
export async function finishGatewayStartup(params: {
|
||||
kernelRuntime: GatewayStartedRuntime;
|
||||
port: number;
|
||||
bootId: string;
|
||||
opts: GatewayStartedRuntime["opts"];
|
||||
log: GatewayLogger;
|
||||
logHealth: GatewayLogger;
|
||||
@@ -42,6 +43,7 @@ export async function finishGatewayStartup(params: {
|
||||
const {
|
||||
kernelRuntime: runtime,
|
||||
port,
|
||||
bootId,
|
||||
opts,
|
||||
log,
|
||||
logHealth,
|
||||
@@ -148,6 +150,7 @@ export async function finishGatewayStartup(params: {
|
||||
attachGatewayWsHandlers({
|
||||
wss,
|
||||
clients,
|
||||
bootId,
|
||||
preauthConnectionBudget,
|
||||
port,
|
||||
gatewayHost: bindHost ?? undefined,
|
||||
|
||||
@@ -21,6 +21,7 @@ export function attachGatewayWsHandlers(params: GatewayWsRuntimeParams) {
|
||||
attachGatewayWsConnectionHandler({
|
||||
wss: params.wss,
|
||||
clients: params.clients,
|
||||
bootId: params.bootId,
|
||||
preauthConnectionBudget: params.preauthConnectionBudget,
|
||||
port: params.port,
|
||||
gatewayHost: params.gatewayHost,
|
||||
|
||||
@@ -283,6 +283,7 @@ describe("gateway pre-auth hardening", () => {
|
||||
attachGatewayWsConnectionHandler({
|
||||
wss,
|
||||
clients,
|
||||
bootId: "preauth-hardening-test-boot",
|
||||
preauthConnectionBudget,
|
||||
port: 0,
|
||||
getResolvedAuth: () => resolvedAuth,
|
||||
|
||||
@@ -221,6 +221,7 @@ class PublicWorkerHarness {
|
||||
attachGatewayWsConnectionHandler({
|
||||
wss: this.wss,
|
||||
clients: this.clients,
|
||||
bootId: "worker-ingress-test-boot",
|
||||
preauthConnectionBudget: this.preauthBudget,
|
||||
port: 0,
|
||||
getResolvedAuth: () => RESOLVED_AUTH,
|
||||
|
||||
@@ -117,6 +117,7 @@ export function attachGatewayWsForTest(params: {
|
||||
params.attach({
|
||||
wss,
|
||||
clients: clients as never,
|
||||
bootId: "ws-test-boot",
|
||||
preauthConnectionBudget: { release: vi.fn() } as never,
|
||||
port: 19001,
|
||||
getResolvedAuth: () => createResolvedGatewayTokenAuth("token"),
|
||||
|
||||
@@ -635,11 +635,13 @@ describe("attachGatewayWsConnectionHandler", () => {
|
||||
it.each([1001, 1006])(
|
||||
"demotes local app startup abort code %i before the first frame",
|
||||
async (closeCode) => {
|
||||
let startupPending = true;
|
||||
const { socket, logWsControl } = await connectTestWs({
|
||||
headers: { "user-agent": "OpenClaw/2607000290 CFNetwork/3860 Darwin/25" },
|
||||
options: { isStartupPending: () => true },
|
||||
options: { isStartupPending: () => startupPending },
|
||||
});
|
||||
|
||||
startupPending = false;
|
||||
socket.emit("close", closeCode, Buffer.alloc(0));
|
||||
|
||||
expect(logWsControl.debug).toHaveBeenCalledWith(
|
||||
|
||||
@@ -70,10 +70,9 @@ import {
|
||||
} from "./ws-types.js";
|
||||
|
||||
type SubsystemLogger = ReturnType<typeof createSubsystemLogger>;
|
||||
|
||||
const MAX_QUEUED_MESSAGE_HANDLER_FRAMES = 16;
|
||||
const unauthorizedCloseBeforeConnectLogLimiter = new HandshakeAuthLogLimiter();
|
||||
type GatewayWsSharedHandlerParams = {
|
||||
bootId: string;
|
||||
wss: WebSocketServer;
|
||||
clients: Set<GatewayWsClient>;
|
||||
preauthConnectionBudget: PreauthConnectionBudget;
|
||||
@@ -81,16 +80,10 @@ type GatewayWsSharedHandlerParams = {
|
||||
gatewayHost?: string;
|
||||
pluginSurfaceScheme?: "http" | "https";
|
||||
getPluginNodeCapabilities?: () => PluginNodeCapabilitySurface[];
|
||||
/**
|
||||
* Auth is read per connection, not per process: a reload can rotate it while
|
||||
* this handler stays attached. One getter keeps that the only source, so no
|
||||
* caller can hand over a snapshot that silently outlives the config it came from.
|
||||
*/
|
||||
// Read per connection so reloads cannot leave a stale auth snapshot.
|
||||
getResolvedAuth: () => ResolvedGatewayAuth;
|
||||
getRequiredSharedGatewaySessionGeneration?: () => string | undefined;
|
||||
/** Optional rate limiter for auth brute-force protection. */
|
||||
rateLimiter?: AuthRateLimiter;
|
||||
/** Browser-origin fallback limiter (loopback is never exempt). */
|
||||
browserRateLimiter?: AuthRateLimiter;
|
||||
nodeReapprovalCoordinator?: NodeReapprovalCoordinator;
|
||||
preauthHandshakeTimeoutMs?: number;
|
||||
@@ -124,7 +117,7 @@ function attachGatewayWsMessageHandlerOnDemand(
|
||||
): void {
|
||||
const queued: RawData[] = [];
|
||||
const queueMessage = (data: RawData) => {
|
||||
if (queued.length >= MAX_QUEUED_MESSAGE_HANDLER_FRAMES) {
|
||||
if (queued.length >= 16) {
|
||||
params.setCloseCause("message-handler-loading-overflow", {
|
||||
queuedFrames: queued.length,
|
||||
});
|
||||
@@ -203,12 +196,10 @@ export function attachGatewayWsConnectionHandler(params: AttachGatewayWsConnecti
|
||||
workerConnectionService,
|
||||
} = params;
|
||||
const originCheckMetrics: WsOriginCheckMetrics = { hostHeaderFallbackAccepted: 0 };
|
||||
|
||||
wss.on("connection", (socket, upgradeReq) => {
|
||||
let client: GatewayWsClient | null = null;
|
||||
let closed = false;
|
||||
const openedAt = Date.now();
|
||||
const connId = randomUUID();
|
||||
let client: GatewayWsClient | null = null,
|
||||
closed = false;
|
||||
const [openedAt, connId] = [Date.now(), randomUUID()];
|
||||
const ingressSocket = socket as GatewayIngressWebSocket;
|
||||
const connectionKind = ingressSocket[GATEWAY_WS_CONNECTION_KIND_PROPERTY] ?? "gateway";
|
||||
const publicWorkerIngress =
|
||||
@@ -235,7 +226,6 @@ export function attachGatewayWsConnectionHandler(params: AttachGatewayWsConnecti
|
||||
const forwardedFor = headerValue(upgradeReq.headers["x-forwarded-for"]);
|
||||
const realIp = headerValue(upgradeReq.headers["x-real-ip"]);
|
||||
const openedDuringStartup = isStartupPending?.() === true;
|
||||
|
||||
const pluginNodeCapabilities =
|
||||
connectionKind === "gateway" ? (getPluginNodeCapabilities?.() ?? []) : [];
|
||||
const pluginSurfaceBaseUrl =
|
||||
@@ -351,7 +341,6 @@ export function attachGatewayWsConnectionHandler(params: AttachGatewayWsConnecti
|
||||
return { kind: "unavailable" } as const;
|
||||
}
|
||||
if (socket.readyState !== WEBSOCKET_OPEN_READY_STATE) {
|
||||
// Keep pending node results revocable until their close handler drains admitted work.
|
||||
if (client?.connect.role === "node" && nodeLifecycleDispatch.hasActive()) {
|
||||
retainClientUntilNodeDrain = true;
|
||||
}
|
||||
@@ -473,8 +462,7 @@ export function attachGatewayWsConnectionHandler(params: AttachGatewayWsConnecti
|
||||
? logWsControl.debug
|
||||
: logWsControl.warn;
|
||||
const authReason = stringMetaValue(closeMeta, "authReason");
|
||||
// This pre-connect close path has no client object yet; treat only
|
||||
// missing shared credentials as suppressible startup retry noise.
|
||||
// Only missing shared credentials are suppressible startup retry noise.
|
||||
const shouldLimitMissingAuthClose =
|
||||
closeCause === "unauthorized" &&
|
||||
shouldLimitMissingCredentialAuthLog({
|
||||
@@ -519,9 +507,7 @@ export function attachGatewayWsConnectionHandler(params: AttachGatewayWsConnecti
|
||||
const context = buildRequestContext();
|
||||
cleanupTalkConnection(connId, logGateway);
|
||||
context.unsubscribeAllSessionEvents(connId);
|
||||
// Detach (or, with a zero grace period, kill) any PTY shells this
|
||||
// connection owned; detached sessions stay reattachable via
|
||||
// terminal.attach until their reaper fires.
|
||||
// Detach or kill owned PTY shells; detached sessions remain reattachable until reaped.
|
||||
context.terminalSessions?.handleDisconnect(connId);
|
||||
let currentDisconnectedNodeId: string | null = null;
|
||||
let disconnectedNodeHistory:
|
||||
@@ -543,8 +529,7 @@ export function attachGatewayWsConnectionHandler(params: AttachGatewayWsConnecti
|
||||
pairingGeneration: nodeSession.pairingGeneration,
|
||||
};
|
||||
}
|
||||
// Retire I/O immediately, but keep the client revocable until admitted
|
||||
// lifecycle work drains; pairing/token removal must still fence it.
|
||||
// Retire I/O now, but retain revocation until admitted lifecycle work drains.
|
||||
retainClientUntilNodeDrain = true;
|
||||
retireTransport();
|
||||
try {
|
||||
@@ -620,15 +605,13 @@ export function attachGatewayWsConnectionHandler(params: AttachGatewayWsConnecti
|
||||
});
|
||||
|
||||
const setClient = (next: GatewayWsClient) => {
|
||||
// Concurrent connect frames can finish authentication out of order. Keep
|
||||
// one socket owner so a raced finalizer cannot leak a client or ping loop.
|
||||
// Keep one socket owner when concurrent connect frames finish out of order.
|
||||
if (closed || client) {
|
||||
return false;
|
||||
}
|
||||
if (next.worker) {
|
||||
for (const existing of clients) {
|
||||
if (existing.worker?.environmentId === next.worker.environmentId) {
|
||||
// Fence queued frames before transport teardown releases the old handler and timers.
|
||||
existing.invalidated = true;
|
||||
clients.delete(existing);
|
||||
try {
|
||||
@@ -657,9 +640,7 @@ export function attachGatewayWsConnectionHandler(params: AttachGatewayWsConnecti
|
||||
awaitingPong = true;
|
||||
try {
|
||||
socket.ping();
|
||||
} catch {
|
||||
// close() clears the timer; ping can race with a socket already entering CLOSING.
|
||||
}
|
||||
} catch {}
|
||||
}, 25_000);
|
||||
return true;
|
||||
};
|
||||
@@ -702,6 +683,7 @@ export function attachGatewayWsConnectionHandler(params: AttachGatewayWsConnecti
|
||||
upgradeReq,
|
||||
ingressAttribution,
|
||||
connId,
|
||||
bootId: params.bootId,
|
||||
remoteAddr,
|
||||
remotePort,
|
||||
localAddr,
|
||||
|
||||
@@ -48,6 +48,7 @@ export async function sendGatewayHello(
|
||||
): Promise<void> {
|
||||
const {
|
||||
connId,
|
||||
bootId,
|
||||
nodeReapprovalCoordinator,
|
||||
gatewayMethods,
|
||||
events,
|
||||
@@ -126,6 +127,7 @@ export async function sendGatewayHello(
|
||||
server: {
|
||||
version: resolveRuntimeServiceVersion(process.env),
|
||||
...(serverBuildId ? { buildId: serverBuildId } : {}),
|
||||
bootId,
|
||||
controlUiBuildSource,
|
||||
connId,
|
||||
},
|
||||
|
||||
@@ -82,6 +82,7 @@ function makeContext(role: "operator" | "node", scopes: string[]) {
|
||||
return {
|
||||
handler: {
|
||||
connId: `conn-${role}`,
|
||||
bootId: "gateway-boot-a",
|
||||
gatewayMethods: [],
|
||||
events: [],
|
||||
buildRequestContext: () => ({ nodeRegistry: { get: () => undefined } }),
|
||||
@@ -187,6 +188,7 @@ describe("sendGatewayHello update detail scope", () => {
|
||||
}),
|
||||
);
|
||||
expect(helloPayload(context)?.server.buildId).toBe("build-a");
|
||||
expect(helloPayload(context)?.server.bootId).toBe("gateway-boot-a");
|
||||
expect(helloPayload(context)?.server.controlUiBuildSource).toBe("bundled");
|
||||
});
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ export type GatewayWsMessageHandlerParams = {
|
||||
upgradeReq: IncomingMessage;
|
||||
ingressAttribution: GatewayAttributedIngress;
|
||||
connId: string;
|
||||
bootId: string;
|
||||
remoteAddr?: string;
|
||||
remotePort?: number;
|
||||
localAddr?: string;
|
||||
|
||||
@@ -196,6 +196,7 @@ describe("Control UI build admission over WebSocket", () => {
|
||||
},
|
||||
},
|
||||
connId: "legacy-build-connection",
|
||||
bootId: "control-ui-build-admission-test-boot",
|
||||
remoteAddr: "127.0.0.1",
|
||||
localAddr: "127.0.0.1",
|
||||
requestHost: request.headers.host,
|
||||
|
||||
@@ -326,6 +326,7 @@ function attachGatewayHarness(options: {
|
||||
});
|
||||
attachGatewayWsMessageHandler({
|
||||
socket,
|
||||
bootId: "post-connect-health-test-boot",
|
||||
upgradeReq: {
|
||||
headers: {
|
||||
host: requestHost,
|
||||
|
||||
@@ -88,6 +88,7 @@ function attachHarness(params: { deferSocketSend?: boolean; startupPending?: boo
|
||||
|
||||
attachGatewayWsMessageHandler({
|
||||
socket,
|
||||
bootId: "suspension-admission-test-boot",
|
||||
upgradeReq: {
|
||||
headers: { host: "127.0.0.1:19001" },
|
||||
socket: { localAddress: "127.0.0.1", remoteAddress: "127.0.0.1" },
|
||||
|
||||
@@ -23,7 +23,7 @@ import type {
|
||||
PluginTrustedToolPolicyRegistration,
|
||||
} from "./host-hooks.js";
|
||||
import type { PluginAgentToolResultMiddlewareRegistration } from "./registry-types.js";
|
||||
import type { PluginRuntime } from "./runtime/types.js";
|
||||
import { createPluginRuntime } from "./runtime/index.js";
|
||||
import type { SessionCatalogProvider } from "./session-catalog.js";
|
||||
import { normalizePluginToolMatcher } from "./tool-hook-matcher.js";
|
||||
import type {
|
||||
@@ -179,7 +179,7 @@ export function createCapturedPluginRegistration(params?: {
|
||||
source: pluginSource,
|
||||
registrationMode: params?.registrationMode ?? "full",
|
||||
config: params?.config ?? ({} as OpenClawConfig),
|
||||
runtime: {} as PluginRuntime,
|
||||
runtime: createPluginRuntime(),
|
||||
logger: noopLogger,
|
||||
resolvePath: (input) => input,
|
||||
handlers: {
|
||||
|
||||
@@ -622,6 +622,21 @@ export function formatUpdateTargetLabel(
|
||||
return version ? t("updates.target.version", { version }) : null;
|
||||
}
|
||||
|
||||
export function isUpdateActionable(
|
||||
updateAvailable: UpdateAvailable | null | undefined,
|
||||
updateSchedule: UpdateScheduleState | null | undefined,
|
||||
updateBusy: boolean,
|
||||
): boolean {
|
||||
const target = updateSchedule?.target;
|
||||
return Boolean(
|
||||
updateBusy ||
|
||||
updateSchedule?.campaign ||
|
||||
(updateAvailable && updateAvailable.latestVersion !== updateAvailable.currentVersion) ||
|
||||
(updateAvailable?.commitsBehind !== undefined && updateAvailable.commitsBehind > 0) ||
|
||||
(target?.kind === "git" && target.commitsBehind > 0),
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveUpdateStatusBanner(params: {
|
||||
status?: string;
|
||||
reason?: string;
|
||||
|
||||
@@ -194,6 +194,7 @@ type SidebarIdentityMenuParams = {
|
||||
canPairDevice: boolean;
|
||||
basePath: string;
|
||||
gatewayVersion: string | null;
|
||||
updateAttentionDismissed: boolean;
|
||||
profileViewer?: PresenceViewer;
|
||||
offline: boolean;
|
||||
themeMode: ThemeMode;
|
||||
@@ -572,6 +573,7 @@ export function renderSidebarIdentityMenu(params: SidebarIdentityMenuParams) {
|
||||
.variant=${"identity"}
|
||||
.basePath=${params.basePath}
|
||||
.gatewayVersion=${params.gatewayVersion}
|
||||
.updateAttentionDismissed=${params.updateAttentionDismissed}
|
||||
.onNavigate=${(routeId: "about") => {
|
||||
params.onClose();
|
||||
params.onNavigate(routeId);
|
||||
|
||||
@@ -2,17 +2,17 @@
|
||||
// Deliberately client-side chrome (like nav width / dock layout), not gateway
|
||||
// state: dismissing a nag on one device should not acknowledge it everywhere.
|
||||
import { gatewayOriginScope } from "@openclaw/gateway-client/browser";
|
||||
import { asNullableRecord, readStringField } from "@openclaw/normalization-core/record-coerce";
|
||||
import type { UpdateAvailable, UpdateScheduleState } from "../api/types.ts";
|
||||
import { getSafeLocalStorage } from "../local-storage.ts";
|
||||
|
||||
const SIDEBAR_ATTENTION_KINDS = [
|
||||
"updateAvailable",
|
||||
"cronFailed",
|
||||
"cronOverdue",
|
||||
"modelAuthExpired",
|
||||
] as const;
|
||||
export type SidebarAttentionKind = (typeof SIDEBAR_ATTENTION_KINDS)[number];
|
||||
const SIDEBAR_ATTENTION_ITEM_KINDS = ["cronFailed", "cronOverdue", "modelAuthExpired"] as const;
|
||||
export type SidebarAttentionKind = (typeof SIDEBAR_ATTENTION_ITEM_KINDS)[number];
|
||||
|
||||
export type SidebarAttentionDismissals = Partial<Record<SidebarAttentionKind, string[]>>;
|
||||
export type UpdateAttentionDismissal = { version: string; gatewayBootId: string };
|
||||
export type SidebarAttentionDismissals = Partial<Record<SidebarAttentionKind, string[]>> & {
|
||||
updateAvailable?: UpdateAttentionDismissal;
|
||||
};
|
||||
|
||||
// Minimal chip shape the snooze logic needs; keeps this module free of the
|
||||
// component's item type so the two files cannot form an import cycle.
|
||||
@@ -31,12 +31,13 @@ export function loadDismissals(gatewayUrl: string): SidebarAttentionDismissals {
|
||||
}
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(storage.getItem(dismissalStoreKey(gatewayUrl)) ?? "null");
|
||||
if (!parsed || typeof parsed !== "object") {
|
||||
const record = asNullableRecord(parsed);
|
||||
if (!record) {
|
||||
return {};
|
||||
}
|
||||
const result: SidebarAttentionDismissals = {};
|
||||
for (const kind of SIDEBAR_ATTENTION_KINDS) {
|
||||
const value = (parsed as Record<string, unknown>)[kind];
|
||||
for (const kind of SIDEBAR_ATTENTION_ITEM_KINDS) {
|
||||
const value = record[kind];
|
||||
const signatures = Array.isArray(value)
|
||||
? value.filter((entry): entry is string => typeof entry === "string")
|
||||
: typeof value === "string"
|
||||
@@ -46,6 +47,12 @@ export function loadDismissals(gatewayUrl: string): SidebarAttentionDismissals {
|
||||
result[kind] = [...new Set(signatures)];
|
||||
}
|
||||
}
|
||||
const updateAvailable = asNullableRecord(record.updateAvailable);
|
||||
const version = readStringField(updateAvailable, "version");
|
||||
const gatewayBootId = readStringField(updateAvailable, "gatewayBootId");
|
||||
if (version && gatewayBootId) {
|
||||
result.updateAvailable = { version, gatewayBootId };
|
||||
}
|
||||
return result;
|
||||
} catch {
|
||||
return {};
|
||||
@@ -84,6 +91,47 @@ export function addDismissal(
|
||||
return next;
|
||||
}
|
||||
|
||||
export function resolveUpdateAttentionDismissal(params: {
|
||||
gatewayBootId?: string | null;
|
||||
updateAvailable?: UpdateAvailable | null;
|
||||
updateSchedule?: UpdateScheduleState | null;
|
||||
}): UpdateAttentionDismissal | null {
|
||||
const target = params.updateSchedule?.target;
|
||||
const version =
|
||||
(target?.kind === "package" ? target.version : target?.upstreamSha) ??
|
||||
params.updateAvailable?.upstreamSha ??
|
||||
params.updateAvailable?.latestVersion;
|
||||
const gatewayBootId = params.gatewayBootId?.trim();
|
||||
const normalizedVersion = version?.trim();
|
||||
return gatewayBootId && normalizedVersion ? { version: normalizedVersion, gatewayBootId } : null;
|
||||
}
|
||||
|
||||
export function isUpdateAttentionDismissed(
|
||||
dismissals: SidebarAttentionDismissals,
|
||||
current: UpdateAttentionDismissal | null,
|
||||
): boolean {
|
||||
const stored = dismissals.updateAvailable;
|
||||
return Boolean(
|
||||
stored &&
|
||||
current &&
|
||||
stored.version === current.version &&
|
||||
stored.gatewayBootId === current.gatewayBootId,
|
||||
);
|
||||
}
|
||||
|
||||
export function isUpdateAttentionForced(tone: "danger" | "info" | "warn" | null | undefined) {
|
||||
return tone === "warn" || tone === "danger";
|
||||
}
|
||||
|
||||
export function dismissUpdateAttention(
|
||||
gatewayUrl: string,
|
||||
dismissal: UpdateAttentionDismissal,
|
||||
): SidebarAttentionDismissals {
|
||||
const next = { ...loadDismissals(gatewayUrl), updateAvailable: dismissal };
|
||||
saveDismissals(gatewayUrl, next);
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop dismissals whose chip is gone or whose entity set changed, so a state
|
||||
* that clears and later recurs surfaces again instead of staying hidden by a
|
||||
@@ -92,10 +140,11 @@ export function addDismissal(
|
||||
export function pruneDismissals(
|
||||
dismissals: SidebarAttentionDismissals,
|
||||
items: readonly DismissableChip[],
|
||||
updateAvailable: UpdateAttentionDismissal | null = null,
|
||||
): SidebarAttentionDismissals {
|
||||
const next: SidebarAttentionDismissals = {};
|
||||
let changed = false;
|
||||
for (const kind of SIDEBAR_ATTENTION_KINDS) {
|
||||
for (const kind of SIDEBAR_ATTENTION_ITEM_KINDS) {
|
||||
const stored = dismissals[kind];
|
||||
if (!stored) {
|
||||
continue;
|
||||
@@ -110,5 +159,10 @@ export function pruneDismissals(
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (isUpdateAttentionDismissed(dismissals, updateAvailable)) {
|
||||
next.updateAvailable = dismissals.updateAvailable;
|
||||
} else if (dismissals.updateAvailable) {
|
||||
changed = true;
|
||||
}
|
||||
return changed ? next : dismissals;
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ type SidebarAttentionAction =
|
||||
| { kind: "askCustodian"; alert: CustodianAlert };
|
||||
|
||||
export type SidebarAttentionItem = {
|
||||
kind: Exclude<SidebarAttentionKind, "updateAvailable">;
|
||||
kind: SidebarAttentionKind;
|
||||
severity: "error" | "warning";
|
||||
icon: IconName;
|
||||
label: string;
|
||||
|
||||
@@ -24,7 +24,7 @@ type SidebarAttentionPanelParams = {
|
||||
onApprovalDecision: (event: Event, approvalId: string, decision: ExecApprovalDecision) => void;
|
||||
onClose: (restoreFocus: boolean) => void;
|
||||
onDismiss: (item: SidebarAttentionItem) => void;
|
||||
onDismissUpdate: () => void;
|
||||
onDismissUpdate?: () => void;
|
||||
onKeydown: (event: KeyboardEvent) => void;
|
||||
onNavigate: (routeId: NavigationRouteId) => void;
|
||||
onOpen: (item: SidebarAttentionItem) => void;
|
||||
|
||||
@@ -2,16 +2,24 @@
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { GatewayBrowserClient } from "../api/gateway.ts";
|
||||
import type { CronJob, CronJobsListResult, ModelAuthStatusResult } from "../api/types.ts";
|
||||
import type {
|
||||
CronJob,
|
||||
CronJobsListResult,
|
||||
ModelAuthStatusResult,
|
||||
UpdateScheduleState,
|
||||
} from "../api/types.ts";
|
||||
import type { ApplicationContext, ApplicationGateway } from "../app/context.ts";
|
||||
import { createApplicationContextProvider } from "../test-helpers/application-context.ts";
|
||||
import { createStorageMock as createTestStorageMock } from "../test-helpers/storage.ts";
|
||||
import { waitForFast } from "../test-helpers/wait-for.ts";
|
||||
import {
|
||||
addDismissal,
|
||||
dismissUpdateAttention,
|
||||
dismissalStoreKey,
|
||||
isUpdateAttentionDismissed,
|
||||
loadDismissals,
|
||||
pruneDismissals,
|
||||
resolveUpdateAttentionDismissal,
|
||||
type SidebarAttentionKind,
|
||||
} from "./sidebar-attention-dismissals.ts";
|
||||
import { buildSidebarAttentionItems } from "./sidebar-attention-items.ts";
|
||||
@@ -57,6 +65,9 @@ type SidebarAttentionElement = HTMLElement & {
|
||||
updateComplete: Promise<boolean>;
|
||||
cronJobs: CronJob[];
|
||||
hasUpdateSurface(): boolean;
|
||||
updateSurfaceVisible(): boolean;
|
||||
dismissUpdateSurface(): void;
|
||||
startUpdate(): void;
|
||||
modelAuthStatus: ModelAuthStatusResult | null;
|
||||
loadedAtMs: number;
|
||||
};
|
||||
@@ -526,10 +537,173 @@ describe("update attention", () => {
|
||||
overlaySnapshot.updateCampaignStatusHydrated = true;
|
||||
expect(element.hasUpdateSurface()).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps restart reconciliation visible after update metadata clears", () => {
|
||||
const element = document.createElement("openclaw-sidebar-attention") as SidebarAttentionElement;
|
||||
element.context = {
|
||||
gateway: { snapshot: { phase: "connected" } },
|
||||
overlays: {
|
||||
snapshot: {
|
||||
updateAvailable: null,
|
||||
updateSchedule: null,
|
||||
updateRunning: false,
|
||||
updateReconciliationPending: true,
|
||||
updateStatusBanner: null,
|
||||
},
|
||||
},
|
||||
} as unknown as ApplicationContext;
|
||||
|
||||
expect(element.hasUpdateSurface()).toBe(true);
|
||||
});
|
||||
|
||||
it("dismisses one target for one Gateway boot and resurfaces on either change", () => {
|
||||
vi.stubGlobal("localStorage", createTestStorageMock());
|
||||
const overlaySnapshot = {
|
||||
updateAvailable: {
|
||||
currentVersion: "2026.8.1",
|
||||
latestVersion: "2026.8.2",
|
||||
channel: "latest",
|
||||
},
|
||||
updateSchedule: {
|
||||
channel: "stable",
|
||||
autoEnabled: false,
|
||||
target: { kind: "package" as const, version: "2026.8.2" },
|
||||
},
|
||||
updateCampaignStatusHydrated: true,
|
||||
updateReconciliationPending: false,
|
||||
updateRunning: false,
|
||||
updateStatusBanner: null,
|
||||
};
|
||||
const gatewaySnapshot = {
|
||||
client: {} as GatewayBrowserClient,
|
||||
phase: "connected" as const,
|
||||
hello: {
|
||||
server: { bootId: "boot-a" },
|
||||
auth: { role: "operator", scopes: ["operator.admin", "operator.read"] },
|
||||
features: { methods: ["update.run"] },
|
||||
},
|
||||
};
|
||||
const element = document.createElement("openclaw-sidebar-attention") as SidebarAttentionElement;
|
||||
element.context = {
|
||||
gateway: {
|
||||
connection: { gatewayUrl: "ws://gateway.test" },
|
||||
snapshot: gatewaySnapshot,
|
||||
},
|
||||
overlays: { snapshot: overlaySnapshot },
|
||||
} as unknown as ApplicationContext;
|
||||
(element as unknown as { dismissedScope: string }).dismissedScope = "ws://gateway.test";
|
||||
|
||||
expect(element.updateSurfaceVisible()).toBe(true);
|
||||
element.dismissUpdateSurface();
|
||||
expect(element.updateSurfaceVisible()).toBe(false);
|
||||
expect(loadDismissals("ws://gateway.test").updateAvailable).toEqual({
|
||||
version: "2026.8.2",
|
||||
gatewayBootId: "boot-a",
|
||||
});
|
||||
|
||||
overlaySnapshot.updateSchedule.target.version = "2026.8.3";
|
||||
expect(element.updateSurfaceVisible()).toBe(true);
|
||||
overlaySnapshot.updateSchedule.target.version = "2026.8.2";
|
||||
gatewaySnapshot.hello.server.bootId = "boot-b";
|
||||
expect(element.updateSurfaceVisible()).toBe(true);
|
||||
});
|
||||
|
||||
it("forces a dismissed update back for warning and failure outcomes", () => {
|
||||
vi.stubGlobal("localStorage", createTestStorageMock());
|
||||
const element = document.createElement("openclaw-sidebar-attention") as SidebarAttentionElement;
|
||||
const overlaySnapshot = {
|
||||
updateAvailable: {
|
||||
currentVersion: "2026.8.1",
|
||||
latestVersion: "2026.8.2",
|
||||
channel: "latest",
|
||||
},
|
||||
updateSchedule: null as UpdateScheduleState | null,
|
||||
updateCampaignStatusHydrated: true,
|
||||
updateReconciliationPending: false,
|
||||
updateRunning: false,
|
||||
updateStatusBanner: null as null | { tone: "warn" | "danger"; text: string },
|
||||
};
|
||||
element.context = {
|
||||
gateway: {
|
||||
connection: { gatewayUrl: "ws://gateway.test" },
|
||||
snapshot: {
|
||||
client: {} as GatewayBrowserClient,
|
||||
phase: "connected",
|
||||
hello: {
|
||||
server: { bootId: "boot-a" },
|
||||
auth: { role: "operator", scopes: ["operator.admin", "operator.read"] },
|
||||
features: { methods: ["update.run"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
overlays: { snapshot: overlaySnapshot },
|
||||
} as unknown as ApplicationContext;
|
||||
(element as unknown as { dismissedScope: string }).dismissedScope = "ws://gateway.test";
|
||||
element.dismissUpdateSurface();
|
||||
expect(element.updateSurfaceVisible()).toBe(false);
|
||||
|
||||
overlaySnapshot.updateStatusBanner = { tone: "warn", text: "Update blocked" };
|
||||
expect(element.updateSurfaceVisible()).toBe(true);
|
||||
overlaySnapshot.updateStatusBanner = { tone: "danger", text: "Update failed" };
|
||||
expect(element.updateSurfaceVisible()).toBe(true);
|
||||
|
||||
overlaySnapshot.updateStatusBanner = null;
|
||||
overlaySnapshot.updateRunning = true;
|
||||
expect(element.updateSurfaceVisible()).toBe(true);
|
||||
overlaySnapshot.updateRunning = false;
|
||||
overlaySnapshot.updateReconciliationPending = true;
|
||||
expect(element.updateSurfaceVisible()).toBe(true);
|
||||
overlaySnapshot.updateReconciliationPending = false;
|
||||
overlaySnapshot.updateSchedule = {
|
||||
channel: "stable",
|
||||
autoEnabled: true,
|
||||
target: { kind: "package", version: "2026.8.2" },
|
||||
campaign: {
|
||||
id: "campaign-applying",
|
||||
state: "applying",
|
||||
announcedAtMs: 1,
|
||||
forceAtMs: 2,
|
||||
updatedAtMs: 2,
|
||||
},
|
||||
};
|
||||
expect(element.updateSurfaceVisible()).toBe(true);
|
||||
});
|
||||
|
||||
it("does not start an update when a failure has no actionable target", () => {
|
||||
const runUpdate = vi.fn();
|
||||
const element = document.createElement("openclaw-sidebar-attention") as SidebarAttentionElement;
|
||||
element.context = {
|
||||
gateway: {
|
||||
snapshot: {
|
||||
phase: "connected",
|
||||
hello: {
|
||||
auth: { role: "operator", scopes: ["operator.admin"] },
|
||||
features: { methods: ["update.run"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
overlays: {
|
||||
runUpdate,
|
||||
snapshot: {
|
||||
updateAvailable: null,
|
||||
updateSchedule: null,
|
||||
updateRunning: false,
|
||||
updateStatusBanner: { tone: "danger", text: "Update failed" },
|
||||
},
|
||||
},
|
||||
} as unknown as ApplicationContext;
|
||||
|
||||
element.startUpdate();
|
||||
|
||||
expect(runUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("pruneDismissals", () => {
|
||||
const chip = (kind: SidebarAttentionKind, signature: string) => ({ kind, signature });
|
||||
const chip = (kind: SidebarAttentionKind, signature: string) => ({
|
||||
kind,
|
||||
signature,
|
||||
});
|
||||
|
||||
it("keeps a dismissal while the same entity set is still affected", () => {
|
||||
const dismissals = { cronFailed: ["alpha", "beta"] };
|
||||
@@ -591,3 +765,55 @@ describe("addDismissal", () => {
|
||||
expect(loadDismissals(gatewayUrl)).toEqual({ cronFailed: ["legacy-signature"] });
|
||||
});
|
||||
});
|
||||
|
||||
describe("update dismissal fact", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("uses the canonical package target and persists the literal boot binding", () => {
|
||||
vi.stubGlobal("localStorage", createTestStorageMock());
|
||||
const dismissal = resolveUpdateAttentionDismissal({
|
||||
gatewayBootId: "boot-a",
|
||||
updateAvailable: {
|
||||
currentVersion: "2026.8.1",
|
||||
latestVersion: "2026.8.2",
|
||||
channel: "latest",
|
||||
},
|
||||
updateSchedule: {
|
||||
channel: "stable",
|
||||
autoEnabled: false,
|
||||
target: { kind: "package", version: "2026.8.3" },
|
||||
},
|
||||
});
|
||||
expect(dismissal).toEqual({ version: "2026.8.3", gatewayBootId: "boot-a" });
|
||||
const stored = dismissUpdateAttention("ws://gateway.test", dismissal!);
|
||||
expect(isUpdateAttentionDismissed(stored, dismissal)).toBe(true);
|
||||
expect(
|
||||
JSON.parse(localStorage.getItem(dismissalStoreKey("ws://gateway.test")) ?? "null"),
|
||||
).toEqual({ updateAvailable: { version: "2026.8.3", gatewayBootId: "boot-a" } });
|
||||
});
|
||||
|
||||
it("uses the git target SHA instead of an unchanged package version", () => {
|
||||
expect(
|
||||
resolveUpdateAttentionDismissal({
|
||||
gatewayBootId: "boot-a",
|
||||
updateAvailable: {
|
||||
currentVersion: "2026.8.1",
|
||||
latestVersion: "2026.8.1",
|
||||
channel: "dev",
|
||||
},
|
||||
updateSchedule: {
|
||||
channel: "dev",
|
||||
autoEnabled: true,
|
||||
target: {
|
||||
kind: "git",
|
||||
upstreamRef: "origin/main",
|
||||
upstreamSha: "abcdef1234567890",
|
||||
commitsBehind: 2,
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toEqual({ version: "abcdef1234567890", gatewayBootId: "boot-a" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,25 +9,33 @@ 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 {
|
||||
hasNativeUpdateBridge,
|
||||
NATIVE_UPDATE_AVAILABILITY_CHANGED_EVENT,
|
||||
NATIVE_UPDATE_DECLINED_EVENT,
|
||||
} from "../app/native-link-routing.ts";
|
||||
import type { UpdateProgress } from "../app/update-confirmation.ts";
|
||||
import { confirmAndStartUpdate, type UpdateProgress } from "../app/update-confirmation.ts";
|
||||
import { isUpdateActionable } from "../app/update-overlay-helpers.ts";
|
||||
import { t } from "../i18n/index.ts";
|
||||
import { createInitialCronState, loadCronJobsPage } from "../lib/cron/index.ts";
|
||||
import { canCallGatewayMethod } from "../lib/gateway-methods.ts";
|
||||
import { loadModelAuthStatus } from "../lib/model-auth.ts";
|
||||
import { OpenClawLightDomContentsElement } from "../lit/openclaw-element.ts";
|
||||
import { SubscriptionsController } from "../lit/subscriptions-controller.ts";
|
||||
import "../styles/sidebar-footer-update.css";
|
||||
import { icons } from "./icons.ts";
|
||||
import { CUSTODIAN_PANEL_TOGGLE_EVENT } from "./panel-toggle-contract.ts";
|
||||
import {
|
||||
addDismissal,
|
||||
dismissUpdateAttention,
|
||||
dismissalStoreKey,
|
||||
isUpdateAttentionDismissed,
|
||||
isUpdateAttentionForced,
|
||||
loadDismissals,
|
||||
pruneDismissals,
|
||||
resolveUpdateAttentionDismissal,
|
||||
saveDismissals,
|
||||
type SidebarAttentionDismissals,
|
||||
type UpdateAttentionDismissal,
|
||||
} from "./sidebar-attention-dismissals.ts";
|
||||
import {
|
||||
buildSidebarAttentionItems,
|
||||
@@ -249,7 +257,10 @@ class SidebarAttention extends OpenClawLightDomContentsElement {
|
||||
this.nativeUpdateDeclined = true;
|
||||
const snapshot = this.context?.overlays.snapshot;
|
||||
const campaign = snapshot?.updateSchedule?.campaign;
|
||||
const busy = snapshot?.updateRunning || campaign?.state === "applying";
|
||||
const busy =
|
||||
snapshot?.updateRunning ||
|
||||
snapshot?.updateReconciliationPending ||
|
||||
campaign?.state === "applying";
|
||||
if (
|
||||
snapshot &&
|
||||
(snapshot.updateAvailable || campaign) &&
|
||||
@@ -320,12 +331,8 @@ class SidebarAttention extends OpenClawLightDomContentsElement {
|
||||
return;
|
||||
}
|
||||
const items = this.buildItems();
|
||||
const updateSurfaceSignature = this.updateSurfaceSignature();
|
||||
const dismissableItems = updateSurfaceSignature
|
||||
? [...items, { kind: "updateAvailable" as const, signature: updateSurfaceSignature }]
|
||||
: items;
|
||||
const stored = loadDismissals(this.dismissedScope);
|
||||
const pruned = pruneDismissals(stored, dismissableItems);
|
||||
const pruned = pruneDismissals(stored, items, this.updateAttentionDismissal());
|
||||
if (pruned !== stored) {
|
||||
saveDismissals(this.dismissedScope, pruned);
|
||||
}
|
||||
@@ -364,6 +371,9 @@ class SidebarAttention extends OpenClawLightDomContentsElement {
|
||||
return false;
|
||||
}
|
||||
const campaign = snapshot.updateSchedule?.campaign;
|
||||
if (snapshot.updateReconciliationPending) {
|
||||
return true;
|
||||
}
|
||||
const canHydrateCampaign = canCallGatewayMethod(
|
||||
this.context?.gateway.snapshot,
|
||||
"update.status",
|
||||
@@ -377,36 +387,72 @@ class SidebarAttention extends OpenClawLightDomContentsElement {
|
||||
);
|
||||
}
|
||||
|
||||
private updateSurfaceSignature(): string | null {
|
||||
if (!this.hasUpdateSurface()) {
|
||||
return null;
|
||||
}
|
||||
private updateAttentionDismissal(): UpdateAttentionDismissal | null {
|
||||
const snapshot = this.context?.overlays.snapshot;
|
||||
const campaign = snapshot?.updateSchedule?.campaign;
|
||||
return [
|
||||
snapshot?.updateRunning ? "running" : "",
|
||||
campaign?.id ?? "",
|
||||
campaign?.state ?? "",
|
||||
campaign?.updatedAtMs ?? "",
|
||||
snapshot?.updateAvailable?.upstreamSha ?? snapshot?.updateAvailable?.latestVersion ?? "",
|
||||
snapshot?.updateStatusBanner?.tone ?? "",
|
||||
snapshot?.updateStatusBanner?.text ?? "",
|
||||
].join("\n");
|
||||
return resolveUpdateAttentionDismissal({
|
||||
gatewayBootId: this.context?.gateway.snapshot.hello?.server?.bootId,
|
||||
updateAvailable: snapshot?.updateAvailable,
|
||||
updateSchedule: snapshot?.updateSchedule,
|
||||
});
|
||||
}
|
||||
|
||||
private updateSurfaceForced(): boolean {
|
||||
const snapshot = this.context?.overlays.snapshot;
|
||||
return (
|
||||
snapshot?.updateRunning ||
|
||||
snapshot?.updateReconciliationPending ||
|
||||
snapshot?.updateSchedule?.campaign?.state === "applying" ||
|
||||
isUpdateAttentionForced(snapshot?.updateStatusBanner?.tone)
|
||||
);
|
||||
}
|
||||
|
||||
private updateSurfaceVisible(): boolean {
|
||||
const signature = this.updateSurfaceSignature();
|
||||
return Boolean(signature && !this.dismissed.updateAvailable?.includes(signature));
|
||||
return (
|
||||
this.hasUpdateSurface() &&
|
||||
(this.updateSurfaceForced() ||
|
||||
!isUpdateAttentionDismissed(this.dismissed, this.updateAttentionDismissal()))
|
||||
);
|
||||
}
|
||||
|
||||
private dismissUpdateSurface() {
|
||||
const signature = this.updateSurfaceSignature();
|
||||
if (!this.dismissedScope || !signature) {
|
||||
const dismissal = this.updateAttentionDismissal();
|
||||
if (
|
||||
!this.dismissedScope ||
|
||||
!dismissal ||
|
||||
this.updateSurfaceForced() ||
|
||||
!canCallGatewayMethod(this.context?.gateway.snapshot, "update.run", "operator.admin")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.dismissed = addDismissal(this.dismissedScope, "updateAvailable", signature);
|
||||
this.dismissed = dismissUpdateAttention(this.dismissedScope, dismissal);
|
||||
}
|
||||
|
||||
private readonly startUpdate = () => {
|
||||
const context = this.context;
|
||||
const snapshot = context?.overlays.snapshot;
|
||||
const campaign = snapshot?.updateSchedule?.campaign;
|
||||
const busy =
|
||||
snapshot?.updateRunning ||
|
||||
snapshot?.updateReconciliationPending ||
|
||||
campaign?.state === "applying";
|
||||
if (
|
||||
!context ||
|
||||
!snapshot ||
|
||||
busy ||
|
||||
!isUpdateActionable(snapshot.updateAvailable, snapshot.updateSchedule, busy) ||
|
||||
!canCallGatewayMethod(context.gateway.snapshot, "update.run", "operator.admin")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
void confirmAndStartUpdate({
|
||||
startGatewayUpdate: () => void context.overlays.runUpdate(),
|
||||
...(this.watchUpdateProgress ? { watchUpdateProgress: this.watchUpdateProgress } : {}),
|
||||
updateAvailable: snapshot.updateAvailable,
|
||||
updateSchedule: snapshot.updateSchedule,
|
||||
viaNativeApp: !this.nativeUpdateDeclined && hasNativeUpdateBridge(),
|
||||
});
|
||||
};
|
||||
|
||||
private readonly closeOnOutsidePointer = (event: PointerEvent) => {
|
||||
if (!this.panelOpen || event.composedPath().includes(this)) {
|
||||
return;
|
||||
@@ -563,6 +609,23 @@ class SidebarAttention extends OpenClawLightDomContentsElement {
|
||||
return nothing;
|
||||
}
|
||||
const updateSurface = this.updateSurfaceVisible();
|
||||
const updateDismissal = this.updateAttentionDismissal();
|
||||
const updateForced = this.updateSurfaceForced();
|
||||
const overlaySnapshot = this.context.overlays.snapshot;
|
||||
const updateBusy =
|
||||
overlaySnapshot.updateRunning ||
|
||||
overlaySnapshot.updateReconciliationPending ||
|
||||
overlaySnapshot.updateSchedule?.campaign?.state === "applying";
|
||||
const updateActionable = isUpdateActionable(
|
||||
overlaySnapshot.updateAvailable,
|
||||
overlaySnapshot.updateSchedule,
|
||||
updateBusy,
|
||||
);
|
||||
const canUpdate = canCallGatewayMethod(
|
||||
this.context.gateway.snapshot,
|
||||
"update.run",
|
||||
"operator.admin",
|
||||
);
|
||||
const approvalQueue = this.approvalQueue();
|
||||
const items = this.currentItems().toSorted(
|
||||
(left, right) => ITEM_PRIORITY[left.kind] - ITEM_PRIORITY[right.kind],
|
||||
@@ -599,6 +662,39 @@ class SidebarAttention extends OpenClawLightDomContentsElement {
|
||||
>`
|
||||
: nothing}
|
||||
</button>
|
||||
${updateSurface
|
||||
? html`<span class="sidebar-footer-update-slot">
|
||||
<button
|
||||
type="button"
|
||||
class="sidebar-footer-update"
|
||||
aria-label=${t("updates.sidebar.availableTitle")}
|
||||
?disabled=${updateBusy || !updateActionable || !canUpdate}
|
||||
@click=${this.startUpdate}
|
||||
>
|
||||
<span class="sidebar-footer-update__icon" aria-hidden="true"
|
||||
>${updateBusy ? icons.refresh : icons.download}</span
|
||||
>
|
||||
<span class="sidebar-footer-update__label">${t("updates.sidebar.action")}</span>
|
||||
</button>
|
||||
${canUpdate && updateDismissal && !updateForced
|
||||
? html`<openclaw-tooltip
|
||||
class="sidebar-hover-tooltip"
|
||||
.content=${t("updates.sidebar.dismissUntilRestartOrVersion")}
|
||||
.delay=${600}
|
||||
.closeDelay=${300}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="sidebar-footer-update__dismiss"
|
||||
aria-label=${t("updates.sidebar.dismissUntilRestartOrVersion")}
|
||||
@click=${() => this.dismissUpdateSurface()}
|
||||
>
|
||||
${icons.x}
|
||||
</button>
|
||||
</openclaw-tooltip>`
|
||||
: nothing}
|
||||
</span>`
|
||||
: nothing}
|
||||
${this.panelOpen && this.panelRenderer
|
||||
? this.panelRenderer({
|
||||
approvalQueue,
|
||||
@@ -608,7 +704,10 @@ class SidebarAttention extends OpenClawLightDomContentsElement {
|
||||
void this.decideApproval(event, approvalId, decision),
|
||||
onClose: (restoreFocus) => this.closePanel(restoreFocus),
|
||||
onDismiss: (item) => this.dismiss(item),
|
||||
onDismissUpdate: () => this.dismissUpdateSurface(),
|
||||
onDismissUpdate:
|
||||
canUpdate && updateDismissal && !updateForced
|
||||
? () => this.dismissUpdateSurface()
|
||||
: undefined,
|
||||
onKeydown: this.handlePanelKeydown,
|
||||
onNavigate: (routeId) => {
|
||||
this.closePanel(false);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { CONTROL_UI_BUILD_INFO } from "../build-info.ts";
|
||||
import { t } from "../i18n/index.ts";
|
||||
import { shouldHandleNavigationClick } from "../lib/navigation-click.ts";
|
||||
import { OpenClawLightDomContentsElement } from "../lit/openclaw-element.ts";
|
||||
import "../styles/sidebar-footer-update.css";
|
||||
import {
|
||||
formatBuildChipText,
|
||||
formatSettingsBuildLabel,
|
||||
@@ -16,6 +17,7 @@ import "./tooltip.ts";
|
||||
class SidebarBuildChip extends OpenClawLightDomContentsElement {
|
||||
@property({ attribute: false }) basePath = "";
|
||||
@property({ attribute: false }) gatewayVersion: string | null = null;
|
||||
@property({ attribute: false }) updateAttentionDismissed = false;
|
||||
@property({ attribute: false }) onNavigate?: (routeId: "about") => void;
|
||||
@property({ attribute: false }) variant: "compact" | "identity" | "settings" = "compact";
|
||||
|
||||
@@ -24,9 +26,11 @@ class SidebarBuildChip extends OpenClawLightDomContentsElement {
|
||||
this.variant === "settings"
|
||||
? formatSettingsBuildLabel(CONTROL_UI_BUILD_INFO, this.gatewayVersion)
|
||||
: this.variant === "identity"
|
||||
? formatSidebarBuildSubtitle(CONTROL_UI_BUILD_INFO)
|
||||
? this.updateAttentionDismissed
|
||||
? formatSettingsBuildLabel(CONTROL_UI_BUILD_INFO, this.gatewayVersion)
|
||||
: formatSidebarBuildSubtitle(CONTROL_UI_BUILD_INFO)
|
||||
: formatBuildChipText(CONTROL_UI_BUILD_INFO);
|
||||
if (!text) {
|
||||
if (!text && !this.updateAttentionDismissed) {
|
||||
return nothing;
|
||||
}
|
||||
return html`
|
||||
@@ -34,7 +38,9 @@ class SidebarBuildChip extends OpenClawLightDomContentsElement {
|
||||
<a
|
||||
class="sidebar-footer-build"
|
||||
href=${pathForRoute("about", this.basePath)}
|
||||
aria-label=${t("aboutPage.artifactDetails")}
|
||||
aria-label=${this.updateAttentionDismissed
|
||||
? `${t("aboutPage.artifactDetails")}. ${t("updates.sidebar.availableTitle")}`
|
||||
: t("aboutPage.artifactDetails")}
|
||||
@click=${(event: MouseEvent) => {
|
||||
if (!shouldHandleNavigationClick(event)) {
|
||||
return;
|
||||
@@ -42,7 +48,12 @@ class SidebarBuildChip extends OpenClawLightDomContentsElement {
|
||||
event.preventDefault();
|
||||
this.onNavigate?.("about");
|
||||
}}
|
||||
>${text}</a
|
||||
>${text ? html`<span class="sidebar-footer-build__text">${text}</span>` : nothing}
|
||||
${this.updateAttentionDismissed
|
||||
? html`<span class="agent-select__badge sidebar-footer-build__update"
|
||||
>${t("updates.sidebar.availableTitle")}</span
|
||||
>`
|
||||
: nothing}</a
|
||||
>
|
||||
<div slot="content" class="sidebar-hover-card sidebar-build-hover-card">
|
||||
${renderSidebarServerDetails(CONTROL_UI_BUILD_INFO, this.gatewayVersion)}
|
||||
|
||||
@@ -99,7 +99,7 @@ export function renderSidebarApprovalItem(params: {
|
||||
|
||||
export function renderSidebarUpdateSurface(params: {
|
||||
context: ApplicationContext | undefined;
|
||||
onDismiss: () => void;
|
||||
onDismiss?: () => void;
|
||||
onNavigate: () => void;
|
||||
visible: boolean;
|
||||
watchUpdateProgress: ((listener: (progress: UpdateProgress) => void) => () => void) | undefined;
|
||||
@@ -117,7 +117,7 @@ export function renderSidebarUpdateSurface(params: {
|
||||
.updateAvailable=${snapshot.updateAvailable}
|
||||
.updateSchedule=${snapshot.updateSchedule}
|
||||
.heldUpdateCampaignId=${snapshot.heldUpdateCampaignId}
|
||||
.updateBusy=${snapshot.updateRunning}
|
||||
.updateBusy=${snapshot.updateRunning || snapshot.updateReconciliationPending}
|
||||
.statusBanner=${snapshot.updateStatusBanner}
|
||||
.watchUpdateProgress=${params.watchUpdateProgress}
|
||||
.canUpdate=${canCallGatewayMethod(gateway, "update.run", "operator.admin")}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { html, nothing } from "lit";
|
||||
import { keyed } from "lit/directives/keyed.js";
|
||||
import { DEFAULT_SIDEBAR_ENTRIES, serializeSidebarEntry } from "../app-navigation.ts";
|
||||
import { isMobileNavLayout } from "../app/mobile-nav-layout.ts";
|
||||
import { isUpdateActionable } from "../app/update-overlay-helpers.ts";
|
||||
import { readPresenceEntries, resolveCurrentSelfUser } from "../app/user-profile.ts";
|
||||
import { t } from "../i18n/index.ts";
|
||||
import { normalizeAgentLabel } from "../lib/agents/display.ts";
|
||||
@@ -28,6 +29,12 @@ import {
|
||||
import { sessionMenuReasons } from "./session-menu-access.ts";
|
||||
import type { SessionMenuAction } from "./session-menu.ts";
|
||||
import { listAssignableSessionOwners } from "./session-owner-chip.ts";
|
||||
import {
|
||||
isUpdateAttentionDismissed,
|
||||
isUpdateAttentionForced,
|
||||
loadDismissals,
|
||||
resolveUpdateAttentionDismissal,
|
||||
} from "./sidebar-attention-dismissals.ts";
|
||||
import type { SidebarMenusController } from "./sidebar-menus-controller.ts";
|
||||
|
||||
export function renderSidebarCustomizeMenuForController(controller: SidebarMenusController) {
|
||||
@@ -117,11 +124,36 @@ export function renderSidebarIdentityMenuForController(controller: SidebarMenusC
|
||||
presenceEntries: readPresenceEntries(host.sessionData.presencePayload),
|
||||
presenceInstanceId: host.sessionData.presenceInstanceId,
|
||||
});
|
||||
const context = host.sessionDataContext;
|
||||
const overlaySnapshot = context?.overlays.snapshot;
|
||||
const updateAttentionDismissal = resolveUpdateAttentionDismissal({
|
||||
gatewayBootId: context?.gateway.snapshot.hello?.server?.bootId,
|
||||
updateAvailable: overlaySnapshot?.updateAvailable,
|
||||
updateSchedule: overlaySnapshot?.updateSchedule,
|
||||
});
|
||||
const updateAttentionDismissed = Boolean(
|
||||
context &&
|
||||
updateAttentionDismissal &&
|
||||
isUpdateActionable(
|
||||
overlaySnapshot?.updateAvailable,
|
||||
overlaySnapshot?.updateSchedule,
|
||||
Boolean(overlaySnapshot?.updateRunning || overlaySnapshot?.updateReconciliationPending),
|
||||
) &&
|
||||
!overlaySnapshot?.updateRunning &&
|
||||
!overlaySnapshot?.updateReconciliationPending &&
|
||||
overlaySnapshot?.updateSchedule?.campaign?.state !== "applying" &&
|
||||
!isUpdateAttentionForced(overlaySnapshot?.updateStatusBanner?.tone) &&
|
||||
isUpdateAttentionDismissed(
|
||||
loadDismissals(context.gateway.connection.gatewayUrl),
|
||||
updateAttentionDismissal,
|
||||
),
|
||||
);
|
||||
return renderSidebarIdentityMenu({
|
||||
position,
|
||||
canPairDevice: host.canPairDevice,
|
||||
basePath: host.basePath,
|
||||
gatewayVersion: host.gatewayVersion,
|
||||
updateAttentionDismissed,
|
||||
profileViewer: selfUser ? { ...selfUser, watchedSessions: [] } : undefined,
|
||||
offline: host.offline,
|
||||
themeMode: host.themeMode,
|
||||
|
||||
@@ -10,6 +10,7 @@ import { confirmAndStartUpdate, type UpdateProgress } from "../app/update-confir
|
||||
import {
|
||||
formatUpdateCampaignLabel,
|
||||
formatUpdateTargetLabel,
|
||||
isUpdateActionable,
|
||||
type ApplicationStatusBanner,
|
||||
} from "../app/update-overlay-helpers.ts";
|
||||
import { t } from "../i18n/index.ts";
|
||||
@@ -301,9 +302,6 @@ class SidebarUpdateCard extends OpenClawLightDomContentsElement {
|
||||
const update = this.updateAvailable;
|
||||
const campaign = this.updateSchedule?.campaign;
|
||||
const busy = this.updateBusy || campaign?.state === "applying";
|
||||
const hasGitUpdate =
|
||||
this.updateSchedule?.target?.kind === "git" && this.updateSchedule.target.commitsBehind > 0;
|
||||
const hasVersionUpdate = Boolean(update && update.latestVersion !== update.currentVersion);
|
||||
// A running update outranks availability: the gateway drops its update
|
||||
// metadata while it restarts, and the card must not vanish or fall back to
|
||||
// the stale "update available" call to action mid-install.
|
||||
@@ -340,7 +338,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 || hasVersionUpdate || hasGitUpdate);
|
||||
const actionable = isUpdateActionable(update, this.updateSchedule, this.updateBusy);
|
||||
return html`
|
||||
<div
|
||||
class="sidebar-update-card"
|
||||
|
||||
@@ -848,7 +848,7 @@ suite.define(() => {
|
||||
'openclaw-sidebar-update-card[data-attention-kind="updateAvailable"]',
|
||||
);
|
||||
const sidebarAutomation = sidebar.locator('[data-attention-kind="cronFailed"]');
|
||||
await expect.poll(() => sidebar.locator(".sidebar-footer-update").count()).toBe(0);
|
||||
await expect.poll(() => sidebar.locator(".sidebar-footer-update").count()).toBe(1);
|
||||
await sidebar.locator(".sidebar-issues-button").click();
|
||||
await expect.poll(() => sidebarUpdate.count()).toBe(1);
|
||||
await expect.poll(() => sidebarAutomation.count()).toBe(1);
|
||||
|
||||
@@ -79,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").count()).toBe(0);
|
||||
expect(await page.locator(".sidebar-footer-update").count()).toBe(1);
|
||||
expect(pageErrors).toEqual([]);
|
||||
await page.screenshot({ path: path.join(artifactDir, "package-update-failure.png") });
|
||||
},
|
||||
@@ -137,7 +137,7 @@ suite.define(() => {
|
||||
{ exact: true },
|
||||
)
|
||||
.waitFor();
|
||||
expect(await page.locator(".sidebar-footer-update").count()).toBe(0);
|
||||
expect(await page.locator(".sidebar-footer-update").count()).toBe(1);
|
||||
expect(pageErrors).toEqual([]);
|
||||
await page.screenshot({ path: path.join(artifactDir, "coalesced-restart-banner.png") });
|
||||
},
|
||||
|
||||
@@ -62,6 +62,125 @@ async function openConfirmation(page: Page, updateButton: Locator, compact = fal
|
||||
}
|
||||
|
||||
suite.define(() => {
|
||||
it("shares one dismissal across the footer and Inbox until the Gateway boot changes", async () => {
|
||||
await suite.withPage(
|
||||
{ locale: "en-US", serviceWorkers: "block", viewport: { height: 720, width: 1280 } },
|
||||
async ({ page }) => {
|
||||
const gateway = await installMockGateway(page, {
|
||||
gatewayBootId: "gateway-boot-a",
|
||||
operatorScopes: ["operator.admin", "operator.read"],
|
||||
updateAvailable: UPDATE_AVAILABLE,
|
||||
updateSchedule: {
|
||||
channel: "stable",
|
||||
autoEnabled: false,
|
||||
target: { kind: "package", version: "2.0.0" },
|
||||
},
|
||||
});
|
||||
expect((await page.goto(`${suite.server.baseUrl}chat`))?.status()).toBe(200);
|
||||
await gateway.waitForRequest("chat.startup");
|
||||
|
||||
const footerUpdate = page.locator(".sidebar-footer-update");
|
||||
await footerUpdate.waitFor();
|
||||
expect(await footerUpdate.isEnabled()).toBe(true);
|
||||
expect(
|
||||
await page
|
||||
.locator("openclaw-sidebar-attention")
|
||||
.evaluate((attention) =>
|
||||
[...attention.children]
|
||||
.map((child) => child.className)
|
||||
.filter((className) => typeof className === "string" && className.length > 0),
|
||||
),
|
||||
).toEqual(["sr-only", "sidebar-issues-button", "sidebar-footer-update-slot"]);
|
||||
expect((await footerUpdate.boundingBox())?.width).toBe(28);
|
||||
const inboxBox = await page.locator(".sidebar-issues-button").boundingBox();
|
||||
const updateSlotBox = await page.locator(".sidebar-footer-update-slot").boundingBox();
|
||||
expect(inboxBox).not.toBeNull();
|
||||
expect(updateSlotBox).not.toBeNull();
|
||||
expect(updateSlotBox!.x - (inboxBox!.x + inboxBox!.width)).toBe(8);
|
||||
await page.locator(".sidebar-issues-button").click();
|
||||
await page
|
||||
.locator('openclaw-sidebar-update-card[data-attention-kind="updateAvailable"]')
|
||||
.waitFor();
|
||||
await page.locator(".sidebar-issues-button").click();
|
||||
|
||||
await footerUpdate.hover();
|
||||
await expect.poll(async () => (await footerUpdate.boundingBox())?.width).toBe(76);
|
||||
const expandedInboxBox = await page.locator(".sidebar-issues-button").boundingBox();
|
||||
expect(expandedInboxBox).not.toBeNull();
|
||||
const dismissButton = page.locator(".sidebar-footer-update__dismiss");
|
||||
expect(await dismissButton.getAttribute("aria-label")).toBe(
|
||||
"Hide until next restart or update",
|
||||
);
|
||||
await expect
|
||||
.poll(() => dismissButton.evaluate((button) => getComputedStyle(button).opacity))
|
||||
.toBe("1");
|
||||
await dismissButton.hover();
|
||||
await expect.poll(async () => (await footerUpdate.boundingBox())?.width).toBe(76);
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const inboxBoxOnDismiss = await page.locator(".sidebar-issues-button").boundingBox();
|
||||
return Math.abs(inboxBoxOnDismiss!.x - expandedInboxBox!.x);
|
||||
})
|
||||
.toBeLessThanOrEqual(0.5);
|
||||
await dismissButton.click();
|
||||
await footerUpdate.waitFor({ state: "detached" });
|
||||
await page.locator(".sidebar-issues-button").click();
|
||||
expect(
|
||||
await page
|
||||
.locator('openclaw-sidebar-update-card[data-attention-kind="updateAvailable"]')
|
||||
.count(),
|
||||
).toBe(0);
|
||||
await page.locator(".sidebar-issues-button").click();
|
||||
|
||||
await page.locator(".sidebar-identity-card").click();
|
||||
await page.getByText("Update available", { exact: true }).waitFor();
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
await page.reload();
|
||||
await gateway.waitForRequest("chat.startup");
|
||||
expect(await page.locator(".sidebar-footer-update").count()).toBe(0);
|
||||
|
||||
await gateway.setGatewayBootId("gateway-boot-b");
|
||||
await gateway.setOnline(false);
|
||||
await gateway.setOnline(true);
|
||||
await page.locator(".sidebar-footer-update").waitFor();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the update visible but non-dismissible for read-only operators", async () => {
|
||||
await suite.withPage(
|
||||
{ locale: "en-US", serviceWorkers: "block", viewport: { height: 720, width: 1280 } },
|
||||
async ({ page }) => {
|
||||
const gateway = await installMockGateway(page, {
|
||||
gatewayBootId: "gateway-boot-read-only",
|
||||
operatorScopes: ["operator.read"],
|
||||
updateAvailable: UPDATE_AVAILABLE,
|
||||
updateSchedule: {
|
||||
channel: "stable",
|
||||
autoEnabled: false,
|
||||
target: { kind: "package", version: "2.0.0" },
|
||||
},
|
||||
});
|
||||
expect((await page.goto(`${suite.server.baseUrl}chat`))?.status()).toBe(200);
|
||||
await gateway.waitForRequest("chat.startup");
|
||||
|
||||
const footerUpdate = page.locator(".sidebar-footer-update");
|
||||
await footerUpdate.waitFor();
|
||||
expect(await footerUpdate.isDisabled()).toBe(true);
|
||||
expect(await page.locator(".sidebar-footer-update__dismiss").count()).toBe(0);
|
||||
|
||||
await page.locator(".sidebar-issues-button").click();
|
||||
const updateIssue = page.locator(
|
||||
'openclaw-sidebar-update-card[data-attention-kind="updateAvailable"]',
|
||||
);
|
||||
await updateIssue.waitFor();
|
||||
expect(await updateIssue.locator(".sidebar-issues-panel__dismiss").count()).toBe(0);
|
||||
expect(await gateway.getRequests("update.run")).toHaveLength(0);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("opens a confirmation that states the action, target, versions, and restart impact", async () => {
|
||||
await suite.withPage(
|
||||
{ locale: "en-US", serviceWorkers: "block", viewport: { height: 720, width: 1280 } },
|
||||
@@ -216,6 +335,15 @@ suite.define(() => {
|
||||
});
|
||||
|
||||
expect(await gateway.getRequests("update.run")).toHaveLength(1);
|
||||
await page.getByRole("button", { name: "Close", exact: true }).click();
|
||||
const footerUpdate = page.locator(".sidebar-footer-update");
|
||||
await expect.poll(() => footerUpdate.isDisabled()).toBe(true);
|
||||
await page.locator(".sidebar-issues-button").click();
|
||||
const updateIssue = page.locator(
|
||||
'openclaw-sidebar-update-card[data-attention-kind="updateAvailable"]',
|
||||
);
|
||||
await updateIssue.locator("summary").click();
|
||||
expect(await updateIssue.locator(".sidebar-update-card__action").isDisabled()).toBe(true);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -459,6 +459,7 @@ export const en: TranslationMap = {
|
||||
campaignTarget: "{status} · {target}",
|
||||
updating: "Updating Gateway…",
|
||||
availableTitle: "Update available",
|
||||
dismissUntilRestartOrVersion: "Hide until next restart or update",
|
||||
availableSummary: "A newer revision is ready.",
|
||||
blockedTitle: "Update blocked",
|
||||
blockedSummary: "The selected revision could not be applied to this checkout.",
|
||||
|
||||
@@ -3295,6 +3295,7 @@ wa-dropdown-item.session-menu__item::part(submenu-icon) {
|
||||
display: flex;
|
||||
height: 32px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
@@ -3973,7 +3974,9 @@ wa-dropdown.sidebar-identity-menu::part(menu) {
|
||||
/* Host elements are display:contents, so the anchor itself is the flex item;
|
||||
min-width 0 lets it yield to the footer icons and ellipsize. */
|
||||
.sidebar-footer-build {
|
||||
display: block;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--muted);
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
.sidebar-footer-bar:has(.sidebar-footer-update-slot) {
|
||||
padding-inline-end: 88px;
|
||||
}
|
||||
|
||||
.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:is(:hover:has(.sidebar-footer-update:not(:disabled)), :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:is(
|
||||
:hover:has(.sidebar-footer-update:not(:disabled)),
|
||||
: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-slot:is(:hover, :focus-within) .sidebar-footer-update:not(:disabled) {
|
||||
width: 76px;
|
||||
background: var(--inbox-attention-hover);
|
||||
}
|
||||
|
||||
.sidebar-footer-update-slot:hover:has(.sidebar-footer-update:not(:disabled)),
|
||||
.sidebar-footer-update-slot:hover:has(.sidebar-footer-update:not(:disabled))::before,
|
||||
.sidebar-footer-update-slot:hover .sidebar-footer-update:not(:disabled),
|
||||
.sidebar-footer-update-slot:hover:has(.sidebar-footer-update:not(:disabled))
|
||||
.sidebar-footer-update__label,
|
||||
.sidebar-footer-update-slot:hover .sidebar-footer-update__dismiss {
|
||||
transition-delay: var(--duration-slow);
|
||||
}
|
||||
|
||||
.sidebar-footer-update:focus-visible {
|
||||
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__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-slot:is(:hover:has(.sidebar-footer-update:not(:disabled)), :focus-within)
|
||||
.sidebar-footer-update__label {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.sidebar-footer-update__dismiss {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
inset-block-start: -3px;
|
||||
inset-inline-end: -3px;
|
||||
display: inline-grid;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
place-items: center;
|
||||
padding: 0;
|
||||
border: 2px solid var(--sidebar-bg);
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--text-strong);
|
||||
color: var(--bg-elevated);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: scale(0.25);
|
||||
transition:
|
||||
opacity var(--duration-fast) ease,
|
||||
transform var(--duration-fast) ease;
|
||||
}
|
||||
|
||||
.sidebar-footer-update-slot:is(:hover, :focus-within) .sidebar-footer-update__dismiss {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
.sidebar-footer-update__dismiss svg {
|
||||
width: 9px;
|
||||
}
|
||||
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
.sidebar-footer-update__dismiss {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-footer-build__text {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.sidebar-footer-build__update {
|
||||
color: var(--inbox-attention);
|
||||
}
|
||||
@@ -1,9 +1,75 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { GatewayBrowserClient } from "../../api/gateway.ts";
|
||||
import type { ApplicationOverlays } from "../../app/overlays-types.ts";
|
||||
import { dismissUpdateAttention } from "../../components/sidebar-attention-dismissals.ts";
|
||||
import { createGatewayHarness, createSessions, mountSidebar } from "../app-sidebar.ts";
|
||||
import "../../components/app-sidebar.ts";
|
||||
|
||||
describe("AppSidebar footer identity menu", () => {
|
||||
it("keeps a dismissed update as a discreet account-menu chip", async () => {
|
||||
const gatewayHarness = createGatewayHarness({
|
||||
instanceId: "self-instance",
|
||||
} as GatewayBrowserClient);
|
||||
gatewayHarness.publish({
|
||||
hello: {
|
||||
...gatewayHarness.gateway.snapshot.hello!,
|
||||
server: {
|
||||
...gatewayHarness.gateway.snapshot.hello!.server,
|
||||
bootId: "boot-a",
|
||||
},
|
||||
},
|
||||
});
|
||||
const { sidebar, context } = await mountSidebar(
|
||||
gatewayHarness.gateway,
|
||||
createSessions("main", ["agent:main:main"]),
|
||||
);
|
||||
(context.overlays as unknown as { snapshot: ApplicationOverlays["snapshot"] }).snapshot = {
|
||||
...context.overlays.snapshot,
|
||||
updateAvailable: {
|
||||
currentVersion: "2026.8.1",
|
||||
latestVersion: "2026.8.2",
|
||||
channel: "latest",
|
||||
},
|
||||
updateSchedule: null,
|
||||
updateStatusBanner: null,
|
||||
};
|
||||
dismissUpdateAttention("ws://gateway.test", {
|
||||
version: "2026.8.2",
|
||||
gatewayBootId: "boot-a",
|
||||
});
|
||||
sidebar.requestUpdate();
|
||||
await sidebar.updateComplete;
|
||||
|
||||
sidebar.querySelector<HTMLButtonElement>(".sidebar-identity-card")?.click();
|
||||
await sidebar.updateComplete;
|
||||
const buildChip = sidebar.querySelector<HTMLElement>("openclaw-sidebar-build-chip");
|
||||
await (buildChip as (HTMLElement & { updateComplete?: Promise<unknown> }) | null)
|
||||
?.updateComplete;
|
||||
|
||||
expect(
|
||||
(buildChip as (HTMLElement & { updateAttentionDismissed?: boolean }) | null)
|
||||
?.updateAttentionDismissed,
|
||||
).toBe(true);
|
||||
expect(buildChip?.querySelector(".sidebar-footer-build__update")?.textContent?.trim()).toBe(
|
||||
"Update available",
|
||||
);
|
||||
|
||||
(context.overlays as unknown as { snapshot: ApplicationOverlays["snapshot"] }).snapshot = {
|
||||
...context.overlays.snapshot,
|
||||
updateAvailable: {
|
||||
currentVersion: "2026.8.2",
|
||||
latestVersion: "2026.8.2",
|
||||
channel: "latest",
|
||||
},
|
||||
};
|
||||
sidebar.requestUpdate();
|
||||
await sidebar.updateComplete;
|
||||
expect(
|
||||
(buildChip as HTMLElement & { updateAttentionDismissed?: boolean }).updateAttentionDismissed,
|
||||
).toBe(false);
|
||||
expect(buildChip?.querySelector(".sidebar-footer-build__update")).toBeNull();
|
||||
});
|
||||
|
||||
it("owns account utilities, restores focus, and routes Profile", async () => {
|
||||
const fullName = "Ada Lovelace With A Deliberately Long Display Name";
|
||||
const gatewayHarness = createGatewayHarness({
|
||||
|
||||
@@ -278,6 +278,8 @@ export type ControlUiMockGatewayScenario = {
|
||||
devGitBranch?: string;
|
||||
/** Exact immutable Control UI artifact served by the mocked Gateway. */
|
||||
serverBuildId?: string;
|
||||
/** Exact Gateway lifecycle generation served in hello. */
|
||||
gatewayBootId?: string;
|
||||
/** Optional startup update snapshot for rich local mock fixtures. */
|
||||
updateAvailable?: UpdateAvailable | null;
|
||||
/** Optional automatic-update campaign snapshot for rich local mock fixtures. */
|
||||
@@ -510,6 +512,7 @@ export type MockGatewayControls = {
|
||||
) => Promise<void>;
|
||||
resolveDeferred: (method: string, payload?: unknown) => Promise<void>;
|
||||
setOnline: (online: boolean) => Promise<void>;
|
||||
setGatewayBootId: (bootId: string) => Promise<void>;
|
||||
setServerBuildId: (buildId: string) => Promise<void>;
|
||||
setOperatorScopes: (scopes: string[]) => Promise<void>;
|
||||
setHistoryMessages: (messages: unknown[]) => Promise<void>;
|
||||
@@ -880,6 +883,7 @@ function normalizeScenario(
|
||||
deferredMethods: scenario.deferredMethods ?? [],
|
||||
devGitBranch: scenario.devGitBranch?.trim() || "",
|
||||
serverBuildId: scenario.serverBuildId?.trim() || "e2e",
|
||||
gatewayBootId: scenario.gatewayBootId?.trim() || "e2e-gateway-boot",
|
||||
updateAvailable: scenario.updateAvailable ?? null,
|
||||
updateSchedule: scenario.updateSchedule ?? null,
|
||||
controlUiBuildSource: scenario.controlUiBuildSource ?? "bundled",
|
||||
@@ -1007,6 +1011,7 @@ function installControlUiMockGateway(
|
||||
requests: BrowserRequest[];
|
||||
resolveDeferred: (method: string, payload?: unknown) => void;
|
||||
setOnline: (online: boolean) => void;
|
||||
setGatewayBootId: (bootId: string) => void;
|
||||
setServerBuildId: (buildId: string) => void;
|
||||
setOperatorScopes: (scopes: string[]) => void;
|
||||
setHistoryMessages: (messages: unknown[]) => void;
|
||||
@@ -1027,6 +1032,9 @@ function installControlUiMockGateway(
|
||||
const scenario: BrowserScenario = input.scenario;
|
||||
const serverBuildIdStateKey = "openclaw.control-ui-e2e.serverBuildId";
|
||||
let serverBuildId = scenario.serverBuildId;
|
||||
let gatewayBootId =
|
||||
new URL(window.location.href).searchParams.get("mockGatewayBootId")?.trim() ||
|
||||
scenario.gatewayBootId;
|
||||
try {
|
||||
serverBuildId = window.sessionStorage.getItem(serverBuildIdStateKey)?.trim() || serverBuildId;
|
||||
} catch {
|
||||
@@ -1693,6 +1701,7 @@ function installControlUiMockGateway(
|
||||
protocol: protocolVersion,
|
||||
server: {
|
||||
buildId: serverBuildId,
|
||||
bootId: gatewayBootId,
|
||||
controlUiBuildSource: scenario.controlUiBuildSource,
|
||||
connId: "control-ui-e2e",
|
||||
version: scenario.serverVersion,
|
||||
@@ -2303,6 +2312,9 @@ function installControlUiMockGateway(
|
||||
socket.openConnection();
|
||||
}
|
||||
},
|
||||
setGatewayBootId(nextBootId) {
|
||||
gatewayBootId = nextBootId;
|
||||
},
|
||||
setServerBuildId(nextBuildId) {
|
||||
serverBuildId = nextBuildId;
|
||||
try {
|
||||
@@ -2589,6 +2601,21 @@ function createMockGatewayControls(
|
||||
gateway.setOnline(nextOnline);
|
||||
}, online);
|
||||
},
|
||||
async setGatewayBootId(bootId) {
|
||||
await page.evaluate((nextBootId) => {
|
||||
const gateway = (
|
||||
window as Window & {
|
||||
openclawControlUiE2eGateway?: {
|
||||
setGatewayBootId: (bootId: string) => void;
|
||||
};
|
||||
}
|
||||
).openclawControlUiE2eGateway;
|
||||
if (!gateway) {
|
||||
throw new Error("Mock Gateway is not installed");
|
||||
}
|
||||
gateway.setGatewayBootId(nextBootId);
|
||||
}, bootId);
|
||||
},
|
||||
async setServerBuildId(buildId) {
|
||||
await page.evaluate((nextBuildId) => {
|
||||
const gateway = (
|
||||
|
||||
Reference in New Issue
Block a user