feat(ui): unify Control UI offline state into one indicator and a queueing composer hint (#112600)

* feat(ui): unify Control UI offline state into one indicator and a queueing composer hint

The Control UI showed three competing offline surfaces (top connection
banner, agent-card presence dot, sidebar footer label), each deriving its
own view of the same gateway snapshot with inconsistent debounce.

- Centralize the 2s offline debounce as `offlineStable` in the gateway
  store; delete the sidebar-local timer.
- Delete the connection banner and the agent-card presence dot; the
  gateway link is client state, not agent presence.
- The sidebar footer indicator is now canonical: a single offline button
  with reconnect detail, redacted last-error tooltip, and click-to-retry
  (absorbs the banner's "Retry now"). Connected state renders nothing.
- Settings sidebar footer follows the same offline-only rule (no green
  connected dot).
- The composer shows an offline hint strip ("messages will be queued and
  sent when the connection returns") while staying fully usable; the
  existing durable outbox already replays queued sends on reconnect.

* fix(ui): keep offline debounce constant module-local for knip production scan
This commit is contained in:
Peter Steinberger
2026-07-22 03:31:58 -07:00
committed by GitHub
parent 92540b8735
commit 01da67e3ff
52 changed files with 329 additions and 336 deletions
+7 -11
View File
@@ -11,7 +11,6 @@ import {
import type { GatewayAgentRow } from "../api/types.ts";
import "../components/app-sidebar.ts";
import "../components/app-topbar.ts";
import "../components/connection-banner.ts";
import "../components/gateway-url-confirmation.ts";
import "../components/github-link-hovercard-registration.ts";
import "../components/login-gate.ts";
@@ -388,7 +387,7 @@ class OpenClawApp extends OpenClawLightDomElement {
`;
}
// Transport drops after an established session keep the shell mounted
// (offline banner + client auto-retry); the login gate is reserved for
// (offline presentation + client auto-retry); the login gate is reserved for
// credential-less first connects, credential rejections, and manual gate
// submissions. A first connect backed by stored credentials paints the
// connecting splash instead of flashing the login gate; the gate returns
@@ -1597,7 +1596,8 @@ class OpenClawShell extends OpenClawLightDomElement {
activeRouteId: activeRoute,
activeSearch: this.routeState.location?.search ?? "",
activeHash: this.routeState.location?.hash ?? "",
connected: gatewaySnapshot.connected,
offline: gatewaySnapshot.offlineStable,
lastError: gatewaySnapshot.lastError,
version:
context.config.current.serverVersion ??
gatewaySnapshot.hello?.server?.version ??
@@ -1608,6 +1608,7 @@ class OpenClawShell extends OpenClawLightDomElement {
searchQuery: this.settingsSearchQuery,
searchBlockMatches: settingsSearchBlocks,
onExit: () => this.exitSettings(),
onRetryConnect: () => context.gateway.connect(),
onNavigate: (routeId, options) => this.navigate(routeId, options),
onPreload: (routeId) => context.preload(routeId),
onSearchQueryChange: (nextQuery) => {
@@ -1626,6 +1627,8 @@ class OpenClawShell extends OpenClawLightDomElement {
) ?? ""}
.sessionKey=${this.activeSessionKey}
.connected=${gatewaySnapshot.connected}
.offline=${gatewaySnapshot.offlineStable}
.lastError=${gatewaySnapshot.lastError}
.terminalAvailable=${terminalAvailable}
.catalogOpenTarget=${normalizeCatalogOpenTarget(uiSettings.catalogOpenTarget)}
.canPairDevice=${gatewaySnapshot.connected &&
@@ -1649,6 +1652,7 @@ class OpenClawShell extends OpenClawLightDomElement {
.onOpenPalette=${this.openPalette}
.onOpenApprovals=${this.openApprovals}
.onToggleSidebar=${() => this.toggleNavigationSurface()}
.onRetryConnect=${() => context.gateway.connect()}
.onOpenNewSession=${(agentId: string, target?: NewSessionTarget) => {
const search = newSessionSearch(agentId, target);
this.navigate("new-session", { search });
@@ -1685,14 +1689,6 @@ class OpenClawShell extends OpenClawLightDomElement {
: ""}"
.tabIndex=${-1}
>
${gatewaySnapshot.connected
? nothing
: html`<openclaw-connection-banner
.props=${{
lastError: gatewaySnapshot.lastError,
onRetry: () => context.gateway.connect(),
}}
></openclaw-connection-banner>`}
<openclaw-update-banner
.props=${{
statusBanner: overlaySnapshot.controlUiRefreshRequired
+67
View File
@@ -91,6 +91,7 @@ describe("createApplicationGateway reconnecting snapshot", () => {
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
@@ -128,6 +129,72 @@ describe("createApplicationGateway reconnecting snapshot", () => {
expect(gateway.snapshot.reconnecting).toBe(true);
});
it("publishes a stable offline state only after a sustained disconnect", async () => {
vi.useFakeTimers();
const { gateway, current } = createStore();
gateway.start();
current().opts.onHello?.(HELLO);
current().opts.onClose?.({ code: 1006, reason: "socket lost", willRetry: true });
expect(gateway.snapshot.offlineStable).toBe(false);
await vi.advanceTimersByTimeAsync(1_999);
expect(gateway.snapshot.offlineStable).toBe(false);
await vi.advanceTimersByTimeAsync(1);
expect(gateway.snapshot.offlineStable).toBe(true);
});
it("does not publish offline before the gateway starts", async () => {
vi.useFakeTimers();
const { gateway } = createStore();
await vi.advanceTimersByTimeAsync(2_000);
expect(gateway.snapshot.offlineStable).toBe(false);
});
it("keeps a sub-two-second connection blip quiet", async () => {
vi.useFakeTimers();
const { gateway, current } = createStore();
gateway.start();
current().opts.onHello?.(HELLO);
current().opts.onClose?.({ code: 1006, reason: "brief blip", willRetry: true });
await vi.advanceTimersByTimeAsync(1_999);
current().opts.onHello?.(HELLO);
await vi.advanceTimersByTimeAsync(1);
expect(gateway.snapshot.offlineStable).toBe(false);
});
it("clears a stable offline state immediately on reconnect", async () => {
vi.useFakeTimers();
const { gateway, current } = createStore();
gateway.start();
current().opts.onHello?.(HELLO);
current().opts.onClose?.({ code: 1006, reason: "socket lost", willRetry: true });
await vi.advanceTimersByTimeAsync(2_000);
expect(gateway.snapshot.offlineStable).toBe(true);
current().opts.onHello?.(HELLO);
expect(gateway.snapshot.offlineStable).toBe(false);
});
it("clears the pending offline timer when stopped", async () => {
vi.useFakeTimers();
const { gateway, current } = createStore();
gateway.start();
current().opts.onHello?.(HELLO);
current().opts.onClose?.({ code: 1006, reason: "socket lost", willRetry: true });
gateway.stop();
await vi.advanceTimersByTimeAsync(2_000);
expect(gateway.snapshot.offlineStable).toBe(false);
});
it("drops back to the gate when the client gives up (credential rejection)", () => {
const { gateway, current } = createStore();
gateway.start();
+35 -3
View File
@@ -23,6 +23,8 @@ import { readPresenceEntries, resolveSelfPresenceUser } from "./user-profile.ts"
type GatewayClientFactory = (opts: GatewayBrowserClientOptions) => GatewayBrowserClient;
const defaultClientFactory: GatewayClientFactory = (opts) => new GatewayBrowserClient(opts);
// Grace window before offline presentation appears; reconnects never wait.
const OFFLINE_INDICATOR_DELAY_MS = 2_000;
function sameSelfUser(
left: ApplicationGatewaySnapshot["selfUser"],
@@ -54,6 +56,7 @@ export function createApplicationGateway(
let snapshot: ApplicationGatewaySnapshot = {
client: null,
connected: false,
offlineStable: false,
reconnecting: false,
hello: null,
assistantAgentId: "main",
@@ -67,6 +70,8 @@ export function createApplicationGateway(
// transport drops render as "reconnecting" (shell + banner) instead of
// kicking the operator back to the login gate.
let everConnected = false;
let stopped = true;
let offlineIndicatorTimer: ReturnType<typeof globalThis.setTimeout> | null = null;
const listeners = new Set<(next: ApplicationGatewaySnapshot) => void>();
const eventListeners = new Set<GatewayEventListener>();
const eventLogListeners = new Set<(events: readonly EventLogEntry[]) => void>();
@@ -90,8 +95,31 @@ export function createApplicationGateway(
listener(snapshot);
}
};
const clearOfflineIndicatorTimer = () => {
if (offlineIndicatorTimer !== null) {
globalThis.clearTimeout(offlineIndicatorTimer);
offlineIndicatorTimer = null;
}
};
const scheduleOfflineIndicator = () => {
if (stopped || snapshot.connected || snapshot.offlineStable || offlineIndicatorTimer !== null) {
return;
}
offlineIndicatorTimer = globalThis.setTimeout(() => {
offlineIndicatorTimer = null;
if (!stopped && !snapshot.connected) {
setSnapshot({ ...snapshot, offlineStable: true });
}
}, OFFLINE_INDICATOR_DELAY_MS);
};
const setSnapshot = (next: ApplicationGatewaySnapshot) => {
snapshot = next;
if (next.connected) {
clearOfflineIndicatorTimer();
snapshot = next.offlineStable ? { ...next, offlineStable: false } : next;
} else {
snapshot = next;
scheduleOfflineIndicator();
}
notify();
};
const publishEventLog = () => {
@@ -131,6 +159,7 @@ export function createApplicationGateway(
};
const connect = (overrides: ApplicationGatewayConnectOptions = {}) => {
stopped = false;
const { sessionKey: requestedSessionKey, ...connectionOverrides } = overrides;
const nextConnection = { ...connection, ...connectionOverrides };
const hasRequestedSessionKey = requestedSessionKey !== undefined;
@@ -253,8 +282,8 @@ export function createApplicationGateway(
...snapshot,
client: nextClient,
connected: false,
// Keep the shell mounted while a fresh client attempts (event-gap
// recovery, banner "retry now") when a session already existed.
// Keep the shell mounted while a fresh client attempts event-gap
// recovery or a manual retry when a session already existed.
reconnecting: everConnected,
hello: null,
selfUser: null,
@@ -289,6 +318,8 @@ export function createApplicationGateway(
},
start: () => connect(),
stop: () => {
stopped = true;
clearOfflineIndicatorTimer();
stopClientEvents?.();
stopClientEvents = undefined;
client?.stop();
@@ -298,6 +329,7 @@ export function createApplicationGateway(
...snapshot,
client: null,
connected: false,
offlineStable: false,
reconnecting: false,
hello: null,
selfUser: null,
+2 -1
View File
@@ -5,9 +5,10 @@ import type { AuthenticatedUser } from "./user-profile.ts";
export type ApplicationGatewaySnapshot = {
client: GatewayBrowserClient | null;
connected: boolean;
offlineStable: boolean;
/**
* Disconnected, but a session existed this page lifetime and the client is
* still auto-retrying. The shell stays mounted with an offline banner in
* still auto-retrying. The shell stays mounted with offline presentation in
* this state instead of falling back to the login gate.
*/
reconnecting: boolean;
+1
View File
@@ -40,6 +40,7 @@ function createGatewayHarness(
assistantAgentId: "main",
client: initialClient,
connected: initialConnected,
offlineStable: false,
reconnecting: false,
hello: null,
lastError: null,
+3
View File
@@ -22,6 +22,8 @@ export abstract class AppSidebarBase extends OpenClawLightDomContentsElement {
@property({ attribute: false }) activeWorkboardBoardId = "";
@property({ attribute: false }) enabledRouteIds?: readonly NavigationRouteId[];
@property({ attribute: false }) connected = false;
@property({ attribute: false }) offline = false;
@property({ attribute: false }) lastError: string | null = null;
@property({ attribute: false }) terminalAvailable = false;
@property({ attribute: false }) catalogOpenTarget: CatalogOpenTarget = "viewer";
@property({ attribute: false }) canPairDevice = false;
@@ -44,6 +46,7 @@ export abstract class AppSidebarBase extends OpenClawLightDomContentsElement {
@property({ attribute: false }) onOpenPalette?: () => void;
@property({ attribute: false }) onOpenApprovals?: () => void;
@property({ attribute: false }) onToggleSidebar?: () => void;
@property({ attribute: false }) onRetryConnect?: () => void;
@property({ attribute: false }) onOpenNewSession?: (
agentId: string,
target?: NewSessionTarget,
@@ -486,9 +486,6 @@ export abstract class AppSidebarSessionNavigationElement extends AppSidebarSessi
}
protected agentChipSubtitle(agentId: string): string {
if (!this.connected) {
return t("common.offline");
}
const latest = this.latestAgentSessionRow(agentId);
if (latest?.hasActiveRun) {
return t("agentChip.working");
+14 -51
View File
@@ -1,4 +1,4 @@
import { html, nothing, type PropertyValues } from "lit";
import { html, nothing } from "lit";
import { state } from "lit/decorators.js";
import type { GatewayControlUiPluginTab } from "../api/gateway.ts";
import {
@@ -45,12 +45,11 @@ import {
resolveLobsterRunOutcome,
type LobsterLogoVisitDetail,
} from "./lobster-pet-contract.ts";
import { redactLoginFailureError } from "./login-gate.ts";
const PALETTE_SHORTCUT = /Mac|iP(hone|ad|od)/i.test(globalThis.navigator?.platform ?? "")
? "⌘K"
: "Ctrl K";
const OFFLINE_INDICATOR_DELAY_MS = 2_000;
let lobsterPetModuleLoad: Promise<unknown> | null = null;
let viewerFacepileModuleLoad: Promise<unknown> | null = null;
@@ -88,9 +87,6 @@ function scheduleSidebarChromeLoad() {
class AppSidebar extends AppSidebarSessionListElement {
@state() private logoVisit: LobsterLogoVisitDetail | null = null;
@state() private debouncedDisconnected = false;
private offlineIndicatorTimer: ReturnType<typeof globalThis.setTimeout> | null = null;
constructor() {
super();
@@ -131,44 +127,15 @@ class AppSidebar extends AppSidebarSessionListElement {
override connectedCallback() {
super.connectedCallback();
this.syncOfflineIndicator();
// The decorative pet's large module stays out of startup and upgrades in place.
// Its first visit is at least 15 seconds after load, so idle loading cannot miss one.
scheduleSidebarChromeLoad();
}
override disconnectedCallback() {
this.syncOfflineIndicator(false);
super.disconnectedCallback();
}
protected override willUpdate(changed: PropertyValues<this>) {
super.willUpdate(changed);
if (changed.has("connected")) {
this.syncOfflineIndicator();
}
}
protected override firstUpdated() {
requestAnimationFrame(() => requestAnimationFrame(() => this.classList.add("sidebar-r")));
}
private syncOfflineIndicator(schedule = !this.connected) {
if (this.offlineIndicatorTimer !== null) {
globalThis.clearTimeout(this.offlineIndicatorTimer);
this.offlineIndicatorTimer = null;
}
this.debouncedDisconnected = false;
if (!schedule) {
return;
}
// Both sidebar signals share one grace window so brief transport blips stay quiet.
this.offlineIndicatorTimer = globalThis.setTimeout(() => {
this.offlineIndicatorTimer = null;
this.debouncedDisconnected = true;
}, OFFLINE_INDICATOR_DELAY_MS);
}
private readonly handleLogoVisit = (event: Event) => {
const detail = (event as CustomEvent<LobsterLogoVisitDetail>).detail;
// A lookless visit is a logo scare: the brand mark hides (the img gets
@@ -178,9 +145,6 @@ class AppSidebar extends AppSidebarSessionListElement {
private renderBrand() {
const collapseLabel = t("nav.collapse");
const gatewayStatus = t("chat.gatewayStatus", {
status: this.connected ? t("common.online") : t("common.offline"),
});
const { activeId: cardAgentId, agent: cardAgent, agents: cardAgents } = this.activeChipAgent();
const menuUnread = cardAgents.some((entry) => {
const agentId = normalizeAgentId(entry.id);
@@ -199,8 +163,6 @@ class AppSidebar extends AppSidebarSessionListElement {
.agentName=${cardName}
.avatarUrl=${cardAgent ? resolveAgentAvatarUrl(cardAgent) : null}
.avatarText=${cardAvatarText}
.offline=${this.debouncedDisconnected}
.statusLabel=${gatewayStatus}
.subtitle=${this.agentChipSubtitle(cardAgentId)}
.menuOpen=${this.agentMenuPosition !== null}
.menuUnread=${menuUnread}
@@ -328,9 +290,7 @@ class AppSidebar extends AppSidebarSessionListElement {
/** Zone 5: product chrome recedes to one slim footer bar. */
private renderFooterBar() {
const gatewayStatus = t("chat.gatewayStatus", {
status: this.connected ? t("common.online") : t("common.offline"),
});
const reconnecting = t("connection.reconnecting");
const selfUser = this.connected
? resolveCurrentSelfUser({
snapshotUser: this.context?.gateway.snapshot.selfUser,
@@ -378,16 +338,19 @@ class AppSidebar extends AppSidebarSessionListElement {
.gatewayVersion=${this.gatewayVersion}
.onNavigate=${(routeId: "about") => this.onNavigate?.(routeId)}
></openclaw-sidebar-build-chip>
${this.debouncedDisconnected
? html`<span
${this.offline
? html`<button
type="button"
class="sidebar-footer-bar__status"
role="status"
aria-live="polite"
title=${gatewayStatus}
><span class="sidebar-footer-bar__status-dot" aria-hidden="true"></span>${t(
aria-label=${`${t("common.offline")}${t("connection.retryNow")}`}
title=${this.lastError ? redactLoginFailureError(this.lastError) : reconnecting}
@click=${() => this.onRetryConnect?.()}
>
<span class="sidebar-footer-bar__status-dot" aria-hidden="true"></span>${t(
"common.offline",
)}</span
>`
)}<span class="sidebar-footer-bar__status-detail">${reconnecting}</span>
</button>`
: nothing}
<openclaw-tooltip .content=${t("nav.settings")}>
<button
@@ -539,7 +502,7 @@ class AppSidebar extends AppSidebarSessionListElement {
></openclaw-sidebar-update-card>
<openclaw-lobster-pet
.seed=${lobsterPetSeed(this.sessionKey)}
.mode=${resolveLobsterPetMode(this.connected, this.sessionsResult?.sessions)}
.mode=${resolveLobsterPetMode(!this.offline, this.sessionsResult?.sessions)}
.runOutcome=${resolveLobsterRunOutcome(this.sessionsResult?.sessions)}
.visitsEnabled=${this.lobsterPetVisits}
.soundsEnabled=${this.lobsterPetSounds}
@@ -23,6 +23,7 @@ function createGateway(connected: boolean): GatewayHarness {
let snapshot: ApplicationGatewaySnapshot = {
client,
connected,
offlineStable: false,
reconnecting: !connected,
hello: null,
assistantAgentId: "main",
-42
View File
@@ -1,42 +0,0 @@
// Control UI component renders the offline/reconnecting banner shown while
// the gateway connection is interrupted but the dashboard stays mounted.
import { html, nothing } from "lit";
import { property } from "lit/decorators.js";
import { t } from "../i18n/index.ts";
import { OpenClawLightDomContentsElement } from "../lit/openclaw-element.ts";
import { redactLoginFailureError } from "./login-gate.ts";
type ConnectionBannerProps = {
lastError: string | null;
onRetry: () => void;
};
function renderConnectionBanner(props: ConnectionBannerProps) {
const detail = props.lastError ? redactLoginFailureError(props.lastError) : null;
const hint = t("connection.offlineHint");
return html`
<div class="connection-banner" role="status" aria-live="polite">
<div class="connection-banner__pill" title=${detail ? `${hint}\n${detail}` : hint}>
<span class="connection-banner__dot" aria-hidden="true"></span>
<span class="connection-banner__title">${t("connection.lostTitle")}</span>
<span class="connection-banner__state">${t("connection.reconnecting")}</span>
<span class="connection-banner__sr-hint">${hint}</span>
<button class="connection-banner__retry" type="button" @click=${props.onRetry}>
${t("connection.retryNow")}
</button>
</div>
</div>
`;
}
class ConnectionBanner extends OpenClawLightDomContentsElement {
@property({ attribute: false }) props?: ConnectionBannerProps;
override render() {
return this.props ? renderConnectionBanner(this.props) : nothing;
}
}
if (!customElements.get("openclaw-connection-banner")) {
customElements.define("openclaw-connection-banner", ConnectionBanner);
}
+1 -1
View File
@@ -75,7 +75,7 @@ function resolveDocsLabel(href: string): string {
return t("login.failure.docsAuth");
}
// Shared with the connection banner so no offline surface prints credentials.
// Shared with offline presentation so no disconnected surface prints credentials.
export function redactLoginFailureError(value: string): string {
return value
.replace(
@@ -86,6 +86,7 @@ function createContext(
const snapshot: ApplicationGatewaySnapshot = {
client: connected ? client : null,
connected,
offlineStable: false,
reconnecting: false,
hello: {
auth: {
+55 -7
View File
@@ -27,13 +27,15 @@ describe("settings sidebar search", () => {
renderSettingsSidebar({
basePath: "",
activeRouteId: "config",
connected: true,
offline: false,
lastError: null,
version: "",
updateAvailable: null,
updateRunning: false,
onUpdate: vi.fn(),
searchQuery: "",
onExit: vi.fn(),
onRetryConnect: vi.fn(),
onNavigate,
onSearchQueryChange: vi.fn(),
preloadTimers: new Map(),
@@ -54,7 +56,8 @@ describe("settings sidebar search", () => {
renderSettingsSidebar({
basePath: "",
activeRouteId: "config",
connected: true,
offline: false,
lastError: null,
version: "",
updateAvailable: null,
updateRunning: false,
@@ -68,6 +71,7 @@ describe("settings sidebar search", () => {
},
],
onExit: vi.fn(),
onRetryConnect: vi.fn(),
onNavigate: vi.fn(),
onSearchQueryChange: vi.fn(),
preloadTimers: new Map(),
@@ -89,7 +93,8 @@ describe("settings sidebar search", () => {
renderSettingsSidebar({
basePath: "",
activeRouteId: "config",
connected: true,
offline: false,
lastError: null,
version: "",
updateAvailable: null,
updateRunning: false,
@@ -109,6 +114,7 @@ describe("settings sidebar search", () => {
},
],
onExit: vi.fn(),
onRetryConnect: vi.fn(),
onNavigate,
onSearchQueryChange: vi.fn(),
preloadTimers: new Map(),
@@ -139,7 +145,8 @@ describe("settings sidebar search", () => {
renderSettingsSidebar({
basePath: "",
activeRouteId: "config",
connected: true,
offline: false,
lastError: null,
version: "",
updateAvailable: null,
updateRunning: false,
@@ -154,6 +161,7 @@ describe("settings sidebar search", () => {
},
],
onExit: vi.fn(),
onRetryConnect: vi.fn(),
onNavigate,
onSearchQueryChange: vi.fn(),
preloadTimers: new Map(),
@@ -187,13 +195,15 @@ describe("settings sidebar search", () => {
renderSettingsSidebar({
basePath: "",
activeRouteId: "config",
connected: true,
offline: false,
lastError: null,
version: "",
updateAvailable: null,
updateRunning: false,
onUpdate: vi.fn(),
searchQuery,
onExit: vi.fn(),
onRetryConnect: vi.fn(),
onNavigate,
onSearchQueryChange: (nextQuery) => {
searchQuery = nextQuery;
@@ -265,13 +275,15 @@ describe("settings sidebar search", () => {
renderSettingsSidebar({
basePath: "",
activeRouteId: "config",
connected: true,
offline: false,
lastError: null,
version: "",
updateAvailable: null,
updateRunning: false,
onUpdate: vi.fn(),
searchQuery: "",
onExit: vi.fn(),
onRetryConnect: vi.fn(),
onNavigate: vi.fn(),
onSearchQueryChange: vi.fn(),
preloadTimers: new Map(),
@@ -293,7 +305,8 @@ describe("settings sidebar search", () => {
renderSettingsSidebar({
basePath: "",
activeRouteId: "config",
connected: true,
offline: false,
lastError: null,
version: "1.0.0",
updateAvailable: {
currentVersion: "1.0.0",
@@ -304,6 +317,7 @@ describe("settings sidebar search", () => {
onUpdate,
searchQuery: "",
onExit: vi.fn(),
onRetryConnect: vi.fn(),
onNavigate: vi.fn(),
onSearchQueryChange: vi.fn(),
preloadTimers: new Map(),
@@ -319,4 +333,38 @@ describe("settings sidebar search", () => {
card?.querySelector<HTMLButtonElement>(".sidebar-update-card__action")?.click();
expect(onUpdate).toHaveBeenCalledOnce();
});
it("shows the offline retry action without an online status", () => {
const onRetryConnect = vi.fn();
const renderSidebar = (offline: boolean, lastError: string | null) =>
render(
renderSettingsSidebar({
basePath: "",
activeRouteId: "config",
offline,
lastError,
version: "1.0.0",
updateAvailable: null,
updateRunning: false,
onUpdate: vi.fn(),
searchQuery: "",
onExit: vi.fn(),
onRetryConnect,
onNavigate: vi.fn(),
onSearchQueryChange: vi.fn(),
preloadTimers: new Map(),
}),
container,
);
renderSidebar(false, null);
expect(container.querySelector(".sidebar-footer-bar__status")).toBeNull();
renderSidebar(true, "connection refused?token=settings-secret");
const button = container.querySelector<HTMLButtonElement>(".sidebar-footer-bar__status");
expect(button?.title).toBe("connection refused?[redacted-credential]");
expect(button?.getAttribute("aria-label")).toBe("Offline — Retry now");
button?.click();
expect(onRetryConnect).toHaveBeenCalledOnce();
});
});
+19 -12
View File
@@ -17,6 +17,7 @@ import type { ApplicationNavigationOptions } from "../app/context.ts";
import { t } from "../i18n/index.ts";
import { normalizeLowercaseStringOrEmpty } from "../lib/string-coerce.ts";
import { icons } from "./icons.ts";
import { redactLoginFailureError } from "./login-gate.ts";
import "./sidebar-update-card.ts";
type SettingsSidebarProps = {
@@ -24,7 +25,8 @@ type SettingsSidebarProps = {
activeRouteId: RouteId;
activeSearch?: string;
activeHash?: string;
connected: boolean;
offline: boolean;
lastError: string | null;
version: string;
updateAvailable: UpdateAvailable | null;
updateRunning: boolean;
@@ -32,6 +34,7 @@ type SettingsSidebarProps = {
searchQuery: string;
searchBlockMatches?: readonly SettingsSearchBlock[];
onExit: () => void;
onRetryConnect: () => void;
onNavigate: (routeId: RouteId, options?: ApplicationNavigationOptions) => void;
onPreload?: (routeId: RouteId) => Promise<void> | void;
onSearchQueryChange: (query: string) => void;
@@ -208,9 +211,7 @@ function syncSettingsSearchScrollShadow(nav: HTMLElement) {
}
export function renderSettingsSidebar(props: SettingsSidebarProps) {
const gatewayStatus = t("chat.gatewayStatus", {
status: props.connected ? t("common.online") : t("common.offline"),
});
const reconnecting = t("connection.reconnecting");
const navigationGroups = filterSettingsNavigationGroups(
props.searchQuery,
props.searchBlockMatches ?? [],
@@ -296,14 +297,20 @@ export function renderSettingsSidebar(props: SettingsSidebarProps) {
.onUpdate=${props.onUpdate}
></openclaw-sidebar-update-card>
<footer class="settings-sidebar__footer">
<span
class="sidebar-status__dot ${props.connected
? "sidebar-connection-status--online"
: "sidebar-connection-status--offline"}"
role="img"
aria-label=${gatewayStatus}
></span>
<span class="settings-sidebar__footer-status">${gatewayStatus}</span>
${props.offline
? html`<button
type="button"
class="sidebar-footer-bar__status"
aria-live="polite"
aria-label=${`${t("common.offline")}${t("connection.retryNow")}`}
title=${props.lastError ? redactLoginFailureError(props.lastError) : reconnecting}
@click=${props.onRetryConnect}
>
<span class="sidebar-footer-bar__status-dot" aria-hidden="true"></span>${t(
"common.offline",
)}<span class="sidebar-footer-bar__status-detail">${reconnecting}</span>
</button>`
: nothing}
${props.version
? html`<span class="settings-sidebar__footer-version">${props.version}</span>`
: nothing}
+1 -11
View File
@@ -12,8 +12,6 @@ class SidebarAgentCard extends OpenClawLightDomContentsElement {
@property({ attribute: false }) agentName = "";
@property({ attribute: false }) avatarUrl: string | null = null;
@property({ attribute: false }) avatarText = "";
@property({ attribute: false }) offline = false;
@property({ attribute: false }) statusLabel = "";
@property({ attribute: false }) subtitle = "";
@property({ attribute: false }) menuOpen = false;
/** Unread sessions exist on non-active agents; surfaces next to the name. */
@@ -38,7 +36,7 @@ class SidebarAgentCard extends OpenClawLightDomContentsElement {
class="sidebar-agent-card__main"
aria-haspopup="menu"
aria-expanded=${String(this.menuOpen)}
aria-label="${this.agentName} · ${menuLabel} · ${this.statusLabel}${this.approvalCount > 0
aria-label="${this.agentName} · ${menuLabel}${this.approvalCount > 0
? ` · ${approvalLabel}`
: ""}"
@click=${(event: MouseEvent) => this.onToggleMenu?.(event.currentTarget as HTMLElement)}
@@ -55,14 +53,6 @@ class SidebarAgentCard extends OpenClawLightDomContentsElement {
: html`<span class="sidebar-agent-card__avatar-text" aria-hidden="true"
>${this.avatarText}</span
>`}
${this.offline
? html`<span
class="sidebar-agent-card__presence"
role="img"
aria-label=${this.statusLabel}
title=${this.statusLabel}
></span>`
: nothing}
</span>
<span class="sidebar-agent-card__text">
<span class="sidebar-agent-card__name">
+2 -2
View File
@@ -3158,7 +3158,7 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => {
await composer.waitFor({ state: "visible", timeout: 10_000 });
await gateway.setOnline(false);
await page.locator("openclaw-connection-banner").waitFor({ timeout: 10_000 });
await page.locator(".agent-chat__offline-hint").waitFor({ timeout: 10_000 });
const prompt = "send this when the Gateway returns";
const attachmentName = "offline-proof.txt";
@@ -3301,7 +3301,7 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => {
return proof.attachment || proof.prompt || proof.runId === runId;
})
.toBe(false);
await page.locator("openclaw-connection-banner").waitFor({ state: "detached" });
await page.locator(".agent-chat__offline-hint").waitFor({ state: "detached" });
await expectRequestCountStable(gateway, "chat.send", 1);
if (artifactDir) {
await page.screenshot({ path: `${artifactDir}/03-online-delivered.png`, fullPage: true });
+9 -9
View File
@@ -2226,7 +2226,7 @@ describeControlUiE2e("Control UI new-session page mocked Gateway E2E", () => {
await page.goto(`${server.baseUrl}new?agent=research`);
await page.getByRole("heading", { name: "Research" }).waitFor();
await gateway.setOnline(false);
await page.locator("openclaw-connection-banner").waitFor({ timeout: 10_000 });
await page.locator(".sidebar-footer-bar__status").waitFor({ timeout: 10_000 });
await page.evaluate(() => {
history.pushState(null, "", "new?agent=research&catalog=claude");
@@ -2340,7 +2340,7 @@ describeControlUiE2e("Control UI new-session page mocked Gateway E2E", () => {
const branchRequestsBefore = (await gateway.getRequests("worktrees.branches")).length;
await gateway.setOnline(false);
await page.locator("openclaw-connection-banner").waitFor({ timeout: 10_000 });
await page.locator(".sidebar-footer-bar__status").waitFor({ timeout: 10_000 });
await gateway.setMethodResponse("agents.list", {
agents: [
{
@@ -2397,7 +2397,7 @@ describeControlUiE2e("Control UI new-session page mocked Gateway E2E", () => {
const branchesBeforeSameWorkspaceReconnect = (await gateway.getRequests("worktrees.branches"))
.length;
await gateway.setOnline(false);
await page.locator("openclaw-connection-banner").waitFor({ timeout: 10_000 });
await page.locator(".sidebar-footer-bar__status").waitFor({ timeout: 10_000 });
await gateway.setOnline(true);
await expect
@@ -2461,7 +2461,7 @@ describeControlUiE2e("Control UI new-session page mocked Gateway E2E", () => {
const branchRequests = (await gateway.getRequests("worktrees.branches")).length;
await gateway.deferNext("worktrees.branches");
await gateway.setOnline(false);
await page.locator("openclaw-connection-banner").waitFor({ timeout: 10_000 });
await page.locator(".sidebar-footer-bar__status").waitFor({ timeout: 10_000 });
await gateway.setOnline(true);
await expect
.poll(async () => (await gateway.getRequests("worktrees.branches")).length)
@@ -2530,7 +2530,7 @@ describeControlUiE2e("Control UI new-session page mocked Gateway E2E", () => {
});
const branchRequests = (await gateway.getRequests("worktrees.branches")).length;
await gateway.setOnline(false);
await page.locator("openclaw-connection-banner").waitFor({ timeout: 10_000 });
await page.locator(".sidebar-footer-bar__status").waitFor({ timeout: 10_000 });
await gateway.setOnline(true);
await expect
.poll(async () => (await gateway.getRequests("worktrees.branches")).length)
@@ -2588,7 +2588,7 @@ describeControlUiE2e("Control UI new-session page mocked Gateway E2E", () => {
});
const branchRequests = (await gateway.getRequests("worktrees.branches")).length;
await gateway.setOnline(false);
await page.locator("openclaw-connection-banner").waitFor({ timeout: 10_000 });
await page.locator(".sidebar-footer-bar__status").waitFor({ timeout: 10_000 });
await gateway.setOnline(true);
await expect
.poll(async () => (await gateway.getRequests("worktrees.branches")).length)
@@ -2653,7 +2653,7 @@ describeControlUiE2e("Control UI new-session page mocked Gateway E2E", () => {
});
const branchRequests = (await gateway.getRequests("worktrees.branches")).length;
await gateway.setOnline(false);
await page.locator("openclaw-connection-banner").waitFor({ timeout: 10_000 });
await page.locator(".sidebar-footer-bar__status").waitFor({ timeout: 10_000 });
await gateway.setOnline(true);
await expect
.poll(async () => (await gateway.getRequests("worktrees.branches")).length)
@@ -2729,7 +2729,7 @@ describeControlUiE2e("Control UI new-session page mocked Gateway E2E", () => {
const nodeRequestsBefore = (await gateway.getRequests("node.list")).length;
await gateway.setOnline(false);
await page.locator("openclaw-connection-banner").waitFor({ timeout: 10_000 });
await page.locator(".sidebar-footer-bar__status").waitFor({ timeout: 10_000 });
await gateway.deferNext("node.list");
await gateway.setOnline(true);
await expect
@@ -2958,7 +2958,7 @@ describeControlUiE2e("Control UI new-session page mocked Gateway E2E", () => {
} else {
const agentRequestsBefore = (await gateway.getRequests("agents.list")).length;
await gateway.setOnline(false);
await page.locator("openclaw-connection-banner").waitFor({ timeout: 10_000 });
await page.locator(".sidebar-footer-bar__status").waitFor({ timeout: 10_000 });
await gateway.setOnline(true);
await expect
.poll(async () => (await gateway.getRequests("agents.list")).length)
@@ -839,7 +839,6 @@ describeControlUiE2e("Control UI session management mocked Gateway E2E", () => {
const initialListCount = (await gateway.getRequests("sessions.list")).length;
await gateway.closeLatest(1006, "disconnect proof");
await page.locator(".connection-banner").waitFor({ state: "visible", timeout: 10_000 });
await gateway.deferNext("sessions.list");
await sidebarRow.waitFor({ state: "visible" });
await captureUiProof(page, "sidebar-sessions-during-reconnect.png");
@@ -848,7 +847,6 @@ describeControlUiE2e("Control UI session management mocked Gateway E2E", () => {
await expect
.poll(async () => (await gateway.getRequests("sessions.list")).length, { timeout: 15_000 })
.toBeGreaterThan(initialListCount);
await page.locator(".connection-banner").waitFor({ state: "detached", timeout: 15_000 });
await sidebarRow.waitFor({ state: "visible" });
expect(await sidebarRows.count()).toBe(3);
for (const otherKey of otherSessionKeys) {
+1 -2
View File
@@ -136,9 +136,8 @@ async function proxyReconnect(
expectedSocketCount: number,
): Promise<void> {
await gateway.closeLatest(1001, "proxy idle timeout");
await page.locator("openclaw-connection-banner").waitFor({ state: "visible" });
await expect.poll(() => gateway.getSocketCount(), { timeout: 10_000 }).toBe(expectedSocketCount);
await page.locator("openclaw-connection-banner").waitFor({ state: "hidden" });
expect(await page.locator(".sidebar-footer-bar__status").count()).toBe(0);
}
async function captureProof(page: Page, name: string): Promise<void> {
+1 -3
View File
@@ -2919,9 +2919,7 @@ export const en: TranslationMap = {
eventStale: "Stale thread",
},
connection: {
lostTitle: "Gateway connection lost",
reconnecting: "Reconnecting…",
offlineHint: "Live updates and actions are paused until the connection returns.",
retryNow: "Retry now",
access: {
title: "Gateway Access",
@@ -3797,7 +3795,6 @@ export const en: TranslationMap = {
catalogOpenTargetViewer: "OpenClaw viewer",
catalogOpenTargetTerminal: "Terminal",
onboardingDisabled: "Disabled during setup",
gatewayStatus: "Gateway status: {status}",
commandPaletteTitle: "Search or jump to… (⌘K)",
openCommandPalette: "Open command palette",
docsOpensInNewTab: "{label} (opens in new tab)",
@@ -4084,6 +4081,7 @@ export const en: TranslationMap = {
placeholder: "Message {name}",
placeholderWithAttachments: "Add a message or paste more images...",
placeholderDisconnected: "Connect to the gateway to start chatting...",
offlineHint: "Offline — messages will be queued and sent when the connection returns.",
preparingModel: "Preparing model...",
responding: "{name} is responding...",
sendingMessage: "Sending message...",
@@ -19,6 +19,7 @@ function gateway(): ApplicationContext["gateway"] {
const snapshot: ApplicationGatewaySnapshot = {
client: null,
connected: false,
offlineStable: false,
reconnecting: false,
hello: null,
assistantAgentId: null,
+1
View File
@@ -55,6 +55,7 @@ function snapshot(
return {
client,
connected,
offlineStable: false,
reconnecting: false,
hello: null,
assistantAgentId: null,
@@ -37,6 +37,7 @@ function contextWithGateway(client: GatewayBrowserClient, connected: boolean): A
const snapshot: ApplicationGatewaySnapshot = {
client,
connected,
offlineStable: false,
reconnecting: false,
hello: null,
assistantAgentId: null,
@@ -75,6 +75,7 @@ function createGateway(client: GatewayBrowserClient, connected = true) {
let snapshot: ApplicationGatewaySnapshot = {
client,
connected,
offlineStable: false,
reconnecting: false,
hello: null,
assistantAgentId: "main",
@@ -62,6 +62,7 @@ function createGateway(): TestGateway {
const snapshot: ApplicationGatewaySnapshot = {
client,
connected: true,
offlineStable: false,
reconnecting: false,
hello: null,
assistantAgentId: null,
+14
View File
@@ -143,6 +143,20 @@ afterEach(async () => {
});
describe("renderChatComposer controls", () => {
it("keeps composing enabled and explains queued delivery while offline", () => {
const { container } = renderComposer({ offline: true, draft: "Queue this message" });
expect(container.querySelector(".agent-chat__input--offline")).not.toBeNull();
expect(container.querySelector(".agent-chat__offline-hint")?.textContent?.trim()).toBe(
"Offline — messages will be queued and sent when the connection returns.",
);
expect(container.querySelector<HTMLTextAreaElement>("textarea")?.disabled).toBe(false);
expect(button(container, t("chat.runControls.sendMessage")).disabled).toBe(false);
const online = renderComposer();
expect(online.container.querySelector(".agent-chat__offline-hint")).toBeNull();
});
it("renders and invokes the archived-session banner action", () => {
const onAction = vi.fn();
const { container } = renderComposer({
+1
View File
@@ -3442,6 +3442,7 @@ class ChatPane extends OpenClawLightDomElement {
realtimeTalkVideoPending: state.realtimeTalkVideoPending,
realtimeTalkCameraError: state.realtimeTalkCameraError,
connected: state.connected,
offline: gatewaySnapshot.offlineStable,
gatewayClient: state.client,
composerHoldToRecord: state.settings.composerHoldToRecord,
canSend: catalogKey ? this.catalogSession?.canContinue === true : !selectedSessionArchived,
+2
View File
@@ -136,6 +136,7 @@ export type ChatProps = {
realtimeTalkVideoPending?: boolean;
realtimeTalkCameraError?: boolean;
connected: boolean;
offline?: boolean;
gatewayClient?: GatewayBrowserClient | null;
composerHoldToRecord?: boolean;
canSend: boolean;
@@ -398,6 +399,7 @@ export function renderChat(props: ChatProps) {
sessionKey: props.sessionKey,
currentAgentId: props.currentAgentId,
connected: props.connected,
offline: props.offline,
canSend: props.canSend,
disabledReason: props.disabledReason,
disabledBanner: props.disabledBanner,
@@ -100,6 +100,7 @@ type ChatComposerProps = {
sessionKey: string;
currentAgentId: string;
connected: boolean;
offline?: boolean;
canSend: boolean;
disabledReason: string | null;
disabledBanner?: { text: string; actionLabel: string; onAction: () => void };
@@ -2744,9 +2745,14 @@ export function renderChatComposer(props: ChatComposerProps) {
: nothing}
${showComposer
? html`<div
class="agent-chat__input"
class="agent-chat__input ${props.offline ? "agent-chat__input--offline" : ""}"
@click=${(event: MouseEvent) => focusComposerFromChrome(event, canCompose)}
>
${props.offline
? html`<div class="agent-chat__offline-hint" role="status" aria-live="polite">
${t("chat.composer.offlineHint")}
</div>`
: nothing}
${slashMenuVisible ? renderSlashMenu(requestUpdate, props, visibleDraft) : nothing}
${renderAttachmentPreview(props)}
${props.replyTarget
+1
View File
@@ -38,6 +38,7 @@ function createGateway(client: GatewayBrowserClient, connected: boolean): TestGa
const snapshot: ApplicationGatewaySnapshot = {
client,
connected,
offlineStable: false,
reconnecting: false,
hello: null,
assistantAgentId: null,
@@ -22,6 +22,7 @@ function createContext(request: ReturnType<typeof vi.fn>) {
const snapshot: ApplicationGatewaySnapshot = {
client,
connected: true,
offlineStable: false,
reconnecting: false,
hello: {
type: "hello-ok",
@@ -35,6 +35,7 @@ export function createContext(
let snapshot: ApplicationGatewaySnapshot = {
client,
connected: true,
offlineStable: false,
reconnecting: false,
hello: {
type: "hello-ok" as const,
+1
View File
@@ -35,6 +35,7 @@ function createContext(): ApplicationContext {
const snapshot: ApplicationGatewaySnapshot = {
client: null,
connected: false,
offlineStable: false,
reconnecting: false,
hello: null,
assistantAgentId: "main",
@@ -40,6 +40,7 @@ function gatewayWithClient(
const snapshot: ApplicationGatewaySnapshot = {
client,
connected,
offlineStable: false,
reconnecting: false,
hello: null,
assistantAgentId: null,
@@ -61,6 +61,7 @@ function createContext(request: ReturnType<typeof vi.fn>): ApplicationContext {
const snapshot: ApplicationGatewaySnapshot = {
client,
connected: true,
offlineStable: false,
reconnecting: false,
hello: null,
assistantAgentId: "research",
@@ -63,6 +63,7 @@ function createHarness(initialScopeId: string) {
const snapshot: ApplicationGatewaySnapshot = {
client: { request } as unknown as GatewayBrowserClient,
connected: true,
offlineStable: false,
reconnecting: false,
hello: null,
assistantAgentId: "main",
+2
View File
@@ -50,6 +50,7 @@ function gatewaySnapshot(
return {
client,
connected,
offlineStable: false,
reconnecting: !connected,
hello: null,
assistantAgentId: null,
@@ -63,6 +64,7 @@ function gateway(client: GatewayBrowserClient | null): ApplicationContext["gatew
const snapshot: ApplicationGatewaySnapshot = {
client,
connected: false,
offlineStable: false,
reconnecting: false,
hello: null,
assistantAgentId: null,
+5
View File
@@ -126,6 +126,7 @@ function createExternalPluginPage(
const snapshot: ApplicationGatewaySnapshot = {
client: null,
connected: true,
offlineStable: false,
reconnecting: false,
hello,
assistantAgentId: null,
@@ -424,6 +425,7 @@ describe("PluginPage", () => {
const snapshot: ApplicationGatewaySnapshot = {
client: null,
connected: true,
offlineStable: false,
reconnecting: false,
hello,
assistantAgentId: null,
@@ -494,6 +496,7 @@ describe("PluginPage", () => {
const snapshot: ApplicationGatewaySnapshot = {
client: { request } as unknown as GatewayBrowserClient,
connected: true,
offlineStable: false,
reconnecting: false,
hello,
assistantAgentId: null,
@@ -575,6 +578,7 @@ describe("PluginPage", () => {
const snapshot: ApplicationGatewaySnapshot = {
client,
connected: true,
offlineStable: false,
reconnecting: false,
hello,
assistantAgentId: null,
@@ -645,6 +649,7 @@ describe("PluginPage", () => {
const snapshot: ApplicationGatewaySnapshot = {
client: null,
connected: true,
offlineStable: false,
reconnecting: false,
hello,
assistantAgentId: null,
@@ -79,6 +79,7 @@ function createSnapshot(
return {
client,
connected,
offlineStable: false,
reconnecting: !connected,
hello: {
type: "hello-ok",
@@ -30,6 +30,7 @@ function createContext(
const snapshot: ApplicationGatewaySnapshot = {
client,
connected,
offlineStable: false,
reconnecting: false,
hello: null,
assistantAgentId: "main",
@@ -94,6 +95,7 @@ function createConnectedContext(
let snapshot: ApplicationGatewaySnapshot = {
client: { request } as GatewayBrowserClient,
connected: true,
offlineStable: false,
reconnecting: false,
hello: null,
assistantAgentId: "main",
+1
View File
@@ -46,6 +46,7 @@ function snapshot(
return {
client,
connected,
offlineStable: false,
reconnecting: !connected,
hello: null,
assistantAgentId: null,
@@ -82,6 +82,7 @@ function createGateway(client: GatewayBrowserClient): MutableGateway {
let snapshot: ApplicationGatewaySnapshot = {
client,
connected: true,
offlineStable: false,
reconnecting: false,
hello: null,
assistantAgentId: null,
@@ -30,6 +30,7 @@ function createFixture(
const snapshot: ApplicationGatewaySnapshot = {
client: { request } as unknown as ApplicationGatewaySnapshot["client"],
connected: true,
offlineStable: false,
reconnecting: false,
hello: null,
assistantAgentId: "research",
@@ -67,6 +67,7 @@ function createContext(
const snapshot: ApplicationGatewaySnapshot = {
client,
connected: true,
offlineStable: false,
reconnecting: false,
hello: null,
assistantAgentId: "research",
+1
View File
@@ -22,6 +22,7 @@ function createGateway(client: GatewayBrowserClient) {
const snapshot: ApplicationGatewaySnapshot = {
client,
connected: true,
offlineStable: false,
reconnecting: false,
hello: null,
assistantAgentId: null,
@@ -23,6 +23,7 @@ function contextWithWorkboard(workboard: WorkboardCapability): ApplicationContex
const snapshot: ApplicationGatewaySnapshot = {
client: null,
connected: false,
offlineStable: false,
reconnecting: false,
hello: null,
assistantAgentId: null,
@@ -56,6 +56,7 @@ function gatewayWithSnapshot(client: GatewayBrowserClient | null, connected: boo
const snapshot: ApplicationGatewaySnapshot = {
client,
connected,
offlineStable: false,
reconnecting: false,
hello: null,
assistantAgentId: null,
+15
View File
@@ -1794,6 +1794,21 @@ openclaw-chat-page {
box-shadow: 0 0 0 3px var(--accent-subtle);
}
.agent-chat__input--offline,
.agent-chat__input--offline:focus-within {
border-color: color-mix(in srgb, var(--warn) 38%, var(--border));
box-shadow: 0 0 0 2px color-mix(in srgb, var(--warn) 10%, transparent);
}
.agent-chat__offline-hint {
padding: 5px var(--chat-box-inset);
border-bottom: 1px solid color-mix(in srgb, var(--warn) 22%, transparent);
background: color-mix(in srgb, var(--warn) 7%, transparent);
color: color-mix(in srgb, var(--warn) 72%, var(--text));
font-size: 11px;
line-height: 1.3;
}
@supports (backdrop-filter: blur(1px)) {
.agent-chat__input {
backdrop-filter: blur(12px) saturate(1.6);
-127
View File
@@ -345,133 +345,6 @@
stroke-linecap: round;
}
/* ===========================================
Connection Banner (gateway offline / reconnecting)
=========================================== */
/* Fixed overlay lane under the topbar: connection flaps must not reflow the
page the way the old in-flow banner did, so the pill floats over content. */
.connection-banner {
position: fixed;
top: calc(var(--shell-topbar-height, 44px) + 10px);
left: var(--shell-nav-width, 0px);
right: 0;
/* Above content, below the mobile nav drawer/backdrop (65+). */
z-index: 50;
display: flex;
justify-content: center;
padding: 0 16px;
pointer-events: none;
}
/* Neutral elevated surface; the amber status dot is the only color accent so
the pill reads as quiet system status, not a warning callout. */
.connection-banner__pill {
pointer-events: auto;
display: inline-flex;
align-items: stretch;
min-width: 0;
max-width: 100%;
border-radius: var(--radius-full);
border: 1px solid var(--border);
background: color-mix(in srgb, var(--bg-elevated) 92%, transparent);
backdrop-filter: blur(12px) saturate(1.2);
-webkit-backdrop-filter: blur(12px) saturate(1.2);
box-shadow: var(--shadow-lg);
font-size: 13px;
line-height: 1;
animation: connection-banner-enter 0.25s var(--ease-out);
}
@keyframes connection-banner-enter {
from {
opacity: 0;
transform: translateY(-6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
.connection-banner__pill {
animation: none;
}
}
.connection-banner__dot {
width: 7px;
height: 7px;
align-self: center;
margin-left: 14px;
border-radius: var(--radius-full);
background: var(--warn);
box-shadow: 0 0 8px color-mix(in srgb, var(--warn) 55%, transparent);
animation: pulse-subtle 2s ease-in-out infinite;
flex-shrink: 0;
}
.connection-banner__title {
align-self: center;
margin-left: 9px;
padding: 10px 0;
color: var(--text-strong);
font-weight: 500;
white-space: nowrap;
}
.connection-banner__state {
align-self: center;
margin-left: 7px;
color: var(--muted);
white-space: nowrap;
}
/* Announced by the role="status" region and mirrored in the pill tooltip;
too long to keep visible in the compact pill. */
.connection-banner__sr-hint {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
/* Text action behind a hairline divider so the pill stays a single surface
instead of a capsule-inside-a-capsule. */
.connection-banner__retry {
margin-left: 12px;
padding: 0 14px;
border: none;
border-left: 1px solid var(--border);
border-radius: 0 var(--radius-full) var(--radius-full) 0;
background: transparent;
color: var(--accent);
font-size: 12.5px;
font-weight: 500;
white-space: nowrap;
transition: background var(--duration-fast) var(--ease-out);
}
.connection-banner__retry:hover {
background: color-mix(in srgb, var(--bg-hover) 84%, transparent);
}
.connection-banner__retry:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
}
@media (max-width: 640px) {
.connection-banner__state {
display: none;
}
}
/* ===========================================
Cards - Refined with depth
=========================================== */
+18 -17
View File
@@ -595,12 +595,6 @@ html.openclaw-native-web-chrome .shell:not(.shell--mobile-nav) .sidebar-brand .s
color: var(--muted);
}
.settings-sidebar__footer-status {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.settings-sidebar__footer-version {
margin-left: auto;
flex-shrink: 0;
@@ -2893,17 +2887,6 @@ wa-dropdown.sidebar-session-sort-menu::part(menu) {
object-fit: cover;
}
.sidebar-agent-card__presence {
position: absolute;
right: -1px;
bottom: -1px;
width: 10px;
height: 10px;
border-radius: var(--radius-full);
border: 2px solid var(--sidebar-bg);
background: var(--danger);
}
.sidebar-agent-card__text {
display: flex;
flex-direction: column;
@@ -3221,14 +3204,32 @@ openclaw-tooltip.sidebar-hover-tooltip {
gap: 5px;
flex: none;
padding: 3px 7px;
border: none;
border-radius: var(--radius-full);
background: color-mix(in srgb, var(--danger) 12%, transparent);
color: var(--danger);
cursor: pointer;
font-family: inherit;
font-size: 11px;
font-weight: 600;
line-height: 1;
}
.sidebar-footer-bar__status:hover {
background: color-mix(in srgb, var(--danger) 18%, transparent);
}
.sidebar-footer-bar__status:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
}
.sidebar-footer-bar__status-detail {
font-size: 10px;
font-weight: 500;
opacity: 0.72;
}
.sidebar-footer-bar__status-dot {
width: 6px;
height: 6px;
+19 -31
View File
@@ -349,52 +349,40 @@ describe("AppSidebar agent chip", () => {
expect(onNavigate).not.toHaveBeenCalledWith("config");
});
it("shows connection exceptions only after a sustained disconnect", async () => {
vi.useFakeTimers();
it("renders the canonical offline retry button only for a stable disconnect", async () => {
const gateway = createGateway({} as GatewayBrowserClient);
const { sidebar } = await mountSidebar(gateway, createSessions("main", ["agent:main:main"]));
const presence = () => sidebar.querySelector(".sidebar-agent-card__presence");
const offlinePill = () => sidebar.querySelector(".sidebar-footer-bar__status");
const expectQuiet = () => {
expect(presence()).toBeNull();
expect(offlinePill()).toBeNull();
};
const onRetryConnect = vi.fn();
sidebar.onRetryConnect = onRetryConnect;
sidebar.connected = true;
await sidebar.updateComplete;
expectQuiet();
expect(sidebar.querySelector(".sidebar-footer-bar__status")).toBeNull();
expect(
sidebar.querySelector(".sidebar-agent-card__main")?.getAttribute("aria-label"),
).toContain("Online");
).not.toContain("Online");
sidebar.connected = false;
sidebar.offline = true;
sidebar.lastError = "gateway unavailable?token=sidebar-secret";
await sidebar.updateComplete;
expect(sidebar.querySelector(".sidebar-agent-card__subtitle")?.textContent?.trim()).toBe(
const button = sidebar.querySelector<HTMLButtonElement>(".sidebar-footer-bar__status");
expect(button?.textContent).toContain("Offline");
expect(button?.textContent).toContain("Reconnecting…");
expect(button?.getAttribute("aria-label")).toBe("Offline — Retry now");
expect(button?.getAttribute("aria-live")).toBe("polite");
expect(button?.title).toBe("gateway unavailable?[redacted-credential]");
expect(button?.querySelector(".sidebar-footer-bar__status-dot")).not.toBeNull();
expect(sidebar.querySelector(".sidebar-agent-card__subtitle")?.textContent).not.toContain(
"Offline",
);
await vi.advanceTimersByTimeAsync(1_999);
expectQuiet();
await vi.advanceTimersByTimeAsync(1);
await sidebar.updateComplete;
const pill = offlinePill();
expect(pill?.textContent?.trim()).toBe("Offline");
expect(pill?.getAttribute("aria-live")).toBe("polite");
expect(pill?.getAttribute("title")).toContain("Offline");
expect(pill?.querySelector(".sidebar-footer-bar__status-dot")).not.toBeNull();
expect(presence()).not.toBeNull();
button?.click();
expect(onRetryConnect).toHaveBeenCalledOnce();
sidebar.connected = true;
sidebar.offline = false;
await sidebar.updateComplete;
expectQuiet();
sidebar.connected = false;
await sidebar.updateComplete;
await vi.advanceTimersByTimeAsync(1_000);
sidebar.connected = true;
await sidebar.updateComplete;
await vi.advanceTimersByTimeAsync(2_000);
expectQuiet();
expect(sidebar.querySelector(".sidebar-footer-bar__status")).toBeNull();
});
it("shows a working subtitle while the agent has an active run", async () => {
+4
View File
@@ -36,6 +36,8 @@ export type SidebarLifecycleState = HTMLElement & {
activeWorkboardBoardId: string;
enabledRouteIds?: readonly NavigationRouteId[];
connected: boolean;
offline: boolean;
lastError: string | null;
terminalAvailable: boolean;
catalogOpenTarget: "viewer" | "terminal";
canPairDevice: boolean;
@@ -61,6 +63,7 @@ export type SidebarLifecycleState = HTMLElement & {
updateAvailable: { currentVersion: string; latestVersion: string; channel: string } | null;
updateRunning: boolean;
onUpdate: () => void;
onRetryConnect?: () => void;
onOpenNewSession?: (agentId: string, target?: { catalogId: string }) => void;
variant: "panel" | "drawer";
};
@@ -79,6 +82,7 @@ export function createGatewayHarness(client: GatewayBrowserClient) {
let snapshot: ApplicationGatewaySnapshot = {
client,
connected: true,
offlineStable: false,
reconnecting: false,
hello: null,
assistantAgentId: "main",