mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 13:26:04 -06:00
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:
committed by
GitHub
parent
92540b8735
commit
01da67e3ff
+7
-11
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -40,6 +40,7 @@ function createGatewayHarness(
|
||||
assistantAgentId: "main",
|
||||
client: initialClient,
|
||||
connected: initialConnected,
|
||||
offlineStable: false,
|
||||
reconnecting: false,
|
||||
hello: null,
|
||||
lastError: null,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user