mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-24 19:35:28 -06:00
refactor(ui): sidebar cleanups — shared tooltips, one idle-import helper, cross-tab outbox bridge (#112780)
* refactor(ui): sidebar cleanups — shared tooltips, one idle-import helper, cross-tab outbox bridge Three bounded cleanups following the offline-state work: - Tooltip unification: every sidebar-family tooltip (session-row badges, offline status, agent card, attention actions, brand icons) now uses the shared <openclaw-tooltip> component instead of raw title attrs, completing the direction #112639 started. Aria labels unchanged. - One idle-import helper (ui/src/lib/idle-import.ts): the duplicated idle-load/retry logic from app-sidebar chrome and app-host's outbox loader collapses into createIdleImport (cached promise clears on failure, one idle retry while online, online re-arm, dispose). The helper migration is net negative at its call sites. - Cross-tab outbox bridge: subscribeStoredChatOutboxChanges now also notifies on storage events for the composer outbox keys, installed on first subscribe and removed with the last subscriber, so a message queued in another tab refreshes badges here. * fix(ui): keep idle-import scheduling statement-form for narrow-safe types and consistent-return * fix(ui): give idle-import a strictly void schedule and promise-only load
This commit is contained in:
committed by
GitHub
parent
0f3855aa81
commit
f4e465d295
+21
-48
@@ -51,6 +51,7 @@ import type { ThemeModeChangeDetail } from "../components/theme-mode-toggle.ts";
|
||||
import { i18n, isSupportedLocale, t } from "../i18n/index.ts";
|
||||
import { copyToClipboard } from "../lib/clipboard.ts";
|
||||
import { isGatewayMethodAdvertised } from "../lib/gateway-methods.ts";
|
||||
import { createIdleImport } from "../lib/idle-import.ts";
|
||||
import { isWorkboardEnabledInConfigSnapshot } from "../lib/plugin-activation.ts";
|
||||
import { searchForSession } from "../lib/sessions/index.ts";
|
||||
import "../lib/toast.ts";
|
||||
@@ -145,8 +146,6 @@ type OutboxStoreRuntime = {
|
||||
subscribeStoredChatOutboxChanges: (listener: () => void) => () => void;
|
||||
};
|
||||
|
||||
let outboxStoreModuleLoad: Promise<OutboxStoreRuntime> | null = null;
|
||||
|
||||
function diffAgentRoster(
|
||||
previous: readonly GatewayAgentRow[],
|
||||
next: readonly GatewayAgentRow[],
|
||||
@@ -532,7 +531,10 @@ class OpenClawShell extends OpenClawLightDomElement {
|
||||
private agentRosterRefreshTimer: ReturnType<typeof globalThis.setTimeout> | null = null;
|
||||
private outboxStoreRuntime: OutboxStoreRuntime | null = null;
|
||||
private outboxStoreUnsubscribe: (() => void) | null = null;
|
||||
private outboxStoreRetryAttempted = false;
|
||||
private readonly outboxStoreImport = createIdleImport(
|
||||
() => import("../lib/chat/outbox-store.ts").then((module): OutboxStoreRuntime => module),
|
||||
(runtime) => this.installOutboxStoreRuntime(runtime),
|
||||
);
|
||||
private lastNativeNavState: NativeNavState | undefined;
|
||||
private didConsiderNativeRouteRestore = false;
|
||||
private pendingNativeNewSession = false;
|
||||
@@ -657,7 +659,10 @@ class OpenClawShell extends OpenClawLightDomElement {
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.scheduleOutboxStoreLoad();
|
||||
if (this.outboxStoreRuntime) {
|
||||
this.installOutboxStoreRuntime(this.outboxStoreRuntime);
|
||||
}
|
||||
this.outboxStoreImport.schedule();
|
||||
this.nativeHistoryState = readNativeHistoryState();
|
||||
this.addEventListener(COMMAND_PALETTE_TARGET_EVENT, this.handleCommandPaletteTarget);
|
||||
window.addEventListener(COMMAND_PALETTE_OPEN_EVENT, this.openPalette);
|
||||
@@ -705,57 +710,26 @@ class OpenClawShell extends OpenClawLightDomElement {
|
||||
window.removeEventListener("openclaw:native-new-session", this.handleNativeNewSession);
|
||||
window.removeEventListener(TERMINAL_PANEL_TOGGLE_EVENT, this.handleDeferredTerminalToggle);
|
||||
window.removeEventListener(BROWSER_PANEL_TOGGLE_EVENT, this.handleDeferredBrowserToggle);
|
||||
window.removeEventListener("online", this.loadOutboxStore);
|
||||
this.outboxStoreImport.dispose();
|
||||
this.outboxStoreUnsubscribe?.();
|
||||
this.outboxStoreUnsubscribe = null;
|
||||
this.outboxStoreRuntime = null;
|
||||
this.outboxStoreRetryAttempted = false;
|
||||
setSettingsChangeListener(null);
|
||||
this.resetShellEpochState();
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
private scheduleOutboxStoreLoad() {
|
||||
if ("requestIdleCallback" in window) {
|
||||
requestIdleCallback(this.loadOutboxStore, { timeout: 3000 });
|
||||
} else {
|
||||
setTimeout(this.loadOutboxStore, 1500);
|
||||
private installOutboxStoreRuntime(runtime: OutboxStoreRuntime) {
|
||||
this.outboxStoreRuntime = runtime;
|
||||
if (!this.isConnected) {
|
||||
return;
|
||||
}
|
||||
this.outboxStoreUnsubscribe?.();
|
||||
this.outboxStoreUnsubscribe = runtime.subscribeStoredChatOutboxChanges(() =>
|
||||
this.requestUpdate(),
|
||||
);
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private readonly loadOutboxStore = () => {
|
||||
outboxStoreModuleLoad ??= import("../lib/chat/outbox-store.ts")
|
||||
.then((module): OutboxStoreRuntime => module)
|
||||
.catch((error: unknown) => {
|
||||
outboxStoreModuleLoad = null;
|
||||
throw error;
|
||||
});
|
||||
void outboxStoreModuleLoad
|
||||
.then((runtime) => {
|
||||
if (!this.isConnected) {
|
||||
return;
|
||||
}
|
||||
window.removeEventListener("online", this.loadOutboxStore);
|
||||
this.outboxStoreRetryAttempted = false;
|
||||
this.outboxStoreRuntime = runtime;
|
||||
this.outboxStoreUnsubscribe?.();
|
||||
this.outboxStoreUnsubscribe = runtime.subscribeStoredChatOutboxChanges(() =>
|
||||
this.requestUpdate(),
|
||||
);
|
||||
this.requestUpdate();
|
||||
})
|
||||
.catch(() => {
|
||||
if (!this.isConnected) {
|
||||
return;
|
||||
}
|
||||
window.addEventListener("online", this.loadOutboxStore, { once: true });
|
||||
if (navigator.onLine && !this.outboxStoreRetryAttempted) {
|
||||
this.outboxStoreRetryAttempted = true;
|
||||
this.scheduleOutboxStoreLoad();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
private resetShellEpochState() {
|
||||
this.navDrawerOpen = false;
|
||||
this.navDrawerTrigger = null;
|
||||
@@ -1332,9 +1306,8 @@ class OpenClawShell extends OpenClawLightDomElement {
|
||||
this.syncSidebarWorkboard();
|
||||
// Chunks are usually served by the gateway, so a failed idle load of the
|
||||
// outbox module recovers on reconnect, not only on a browser online event.
|
||||
if (snapshot.connected && !this.outboxStoreRuntime && outboxStoreModuleLoad === null) {
|
||||
this.outboxStoreRetryAttempted = false;
|
||||
this.loadOutboxStore();
|
||||
if (snapshot.connected) {
|
||||
void this.outboxStoreImport.load().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import { t } from "../i18n/index.ts";
|
||||
import { normalizeAgentLabel, resolveAgentTextAvatar } from "../lib/agents/display.ts";
|
||||
import { resolveAgentAvatarUrl } from "../lib/avatar.ts";
|
||||
import { BoardAvailabilityController } from "../lib/board/availability-controller.ts";
|
||||
import { sessionHasBoard } from "../lib/board/provider.ts";
|
||||
import "./menu-surface.ts";
|
||||
import "./session-menu.ts";
|
||||
import "./sidebar-agent-card.ts";
|
||||
@@ -24,8 +25,8 @@ import "./sidebar-build-chip.ts";
|
||||
import "./sidebar-update-card.ts";
|
||||
import "./theme-mode-toggle.ts";
|
||||
import "./tooltip.ts";
|
||||
import { sessionHasBoard } from "../lib/board/provider.ts";
|
||||
import { isGatewayMethodAdvertised } from "../lib/gateway-methods.ts";
|
||||
import { createIdleImport } from "../lib/idle-import.ts";
|
||||
import { searchForSession } from "../lib/sessions/index.ts";
|
||||
import { areUiSessionKeysEquivalent, normalizeAgentId } from "../lib/sessions/session-key.ts";
|
||||
import { pluginTabKey } from "../pages/plugin/route.ts";
|
||||
@@ -51,40 +52,14 @@ import { renderOfflineSidebarStatus, renderSessionRowBadges } from "./session-ro
|
||||
const PALETTE_SHORTCUT = /Mac|iP(hone|ad|od)/i.test(globalThis.navigator?.platform ?? "")
|
||||
? "⌘K"
|
||||
: "Ctrl K";
|
||||
let lobsterPetModuleLoad: Promise<unknown> | null = null;
|
||||
let viewerFacepileModuleLoad: Promise<unknown> | null = null;
|
||||
|
||||
function scheduleSidebarChromeLoad() {
|
||||
if (
|
||||
(lobsterPetModuleLoad || customElements.get("openclaw-lobster-pet")) &&
|
||||
(viewerFacepileModuleLoad || customElements.get("openclaw-viewer-facepile"))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const start = () => {
|
||||
// A failed chunk fetch must not pin a rejected promise forever: clear the
|
||||
// cache and retry when connectivity returns. The sidebar mounts once per
|
||||
// page, so without this a transient failure would disable the pet for the
|
||||
// whole session; a deploy-pruned chunk stays off until reload, by design.
|
||||
if (!customElements.get("openclaw-lobster-pet")) {
|
||||
lobsterPetModuleLoad ??= import("./lobster-pet.ts").catch(() => {
|
||||
lobsterPetModuleLoad = null;
|
||||
window.addEventListener("online", () => start(), { once: true });
|
||||
});
|
||||
}
|
||||
if (!customElements.get("openclaw-viewer-facepile")) {
|
||||
viewerFacepileModuleLoad ??= import("./viewer-facepile.ts").catch(() => {
|
||||
viewerFacepileModuleLoad = null;
|
||||
window.addEventListener("online", () => start(), { once: true });
|
||||
});
|
||||
}
|
||||
};
|
||||
if ("requestIdleCallback" in window) {
|
||||
requestIdleCallback(() => start(), { timeout: 3000 });
|
||||
} else {
|
||||
setTimeout(start, 1500);
|
||||
}
|
||||
}
|
||||
// The shared loader retries transient chunk failures online; a deploy-pruned
|
||||
// chunk still stays off until reload when that retry fails, by design.
|
||||
const sidebarChromeImport = createIdleImport(() =>
|
||||
Promise.all([
|
||||
customElements.get("openclaw-lobster-pet") ? undefined : import("./lobster-pet.ts"),
|
||||
customElements.get("openclaw-viewer-facepile") ? undefined : import("./viewer-facepile.ts"),
|
||||
]),
|
||||
);
|
||||
|
||||
class AppSidebar extends AppSidebarSessionListElement {
|
||||
@state() private logoVisit: LobsterLogoVisitDetail | null = null;
|
||||
@@ -130,7 +105,7 @@ class AppSidebar extends AppSidebarSessionListElement {
|
||||
super.connectedCallback();
|
||||
// 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();
|
||||
sidebarChromeImport.schedule();
|
||||
}
|
||||
|
||||
protected override firstUpdated() {
|
||||
@@ -215,12 +190,13 @@ class AppSidebar extends AppSidebarSessionListElement {
|
||||
this.activeRouteId === "chat" &&
|
||||
areUiSessionKeysEquivalent(this.getRouteSessionKey(), mainKey);
|
||||
const stateBadge = mainRow?.hasActiveRun
|
||||
? html`<span
|
||||
class="session-run-spinner"
|
||||
role="img"
|
||||
aria-label=${t("sessionsView.activeRun")}
|
||||
title=${t("sessionsView.activeRun")}
|
||||
></span>`
|
||||
? html`<openclaw-tooltip .content=${t("sessionsView.activeRun")}>
|
||||
<span
|
||||
class="session-run-spinner"
|
||||
role="img"
|
||||
aria-label=${t("sessionsView.activeRun")}
|
||||
></span>
|
||||
</openclaw-tooltip>`
|
||||
: mainRow?.unread === true && !active
|
||||
? html`<span
|
||||
class="session-unread-dot"
|
||||
@@ -244,25 +220,27 @@ class AppSidebar extends AppSidebarSessionListElement {
|
||||
<span class="nav-item__icon" aria-hidden="true">${icons.home}</span>
|
||||
<span class="nav-item__text">${t("nav.home")}</span>
|
||||
${sessionHasBoard(mainKey)
|
||||
? html`<span
|
||||
class="sidebar-board-glyph"
|
||||
role="img"
|
||||
aria-label=${t("sessionsView.dashboardAvailable")}
|
||||
title=${t("sessionsView.dashboardAvailable")}
|
||||
>${icons.layoutDashboard}</span
|
||||
>`
|
||||
? html`<openclaw-tooltip .content=${t("sessionsView.dashboardAvailable")}>
|
||||
<span
|
||||
class="sidebar-board-glyph"
|
||||
role="img"
|
||||
aria-label=${t("sessionsView.dashboardAvailable")}
|
||||
>${icons.layoutDashboard}</span
|
||||
>
|
||||
</openclaw-tooltip>`
|
||||
: nothing}
|
||||
${stateBadge !== nothing || approvalNeeded || outboxCount > 0
|
||||
? html`<span class="nav-item__state sidebar-home-session-states">
|
||||
${stateBadge}
|
||||
${approvalNeeded
|
||||
? html`<span
|
||||
class="session-approval-badge"
|
||||
role="img"
|
||||
aria-label=${t("sessionsView.approvalNeeded")}
|
||||
title=${t("sessionsView.approvalNeeded")}
|
||||
>${icons.alertTriangle}</span
|
||||
>`
|
||||
? html`<openclaw-tooltip .content=${t("sessionsView.approvalNeeded")}>
|
||||
<span
|
||||
class="session-approval-badge"
|
||||
role="img"
|
||||
aria-label=${t("sessionsView.approvalNeeded")}
|
||||
>${icons.alertTriangle}</span
|
||||
>
|
||||
</openclaw-tooltip>`
|
||||
: nothing}
|
||||
${renderSessionRowBadges({ hasAutomation: false, outboxCount })}
|
||||
</span>`
|
||||
@@ -343,15 +321,12 @@ class AppSidebar extends AppSidebarSessionListElement {
|
||||
.onNavigate=${(routeId: "about") => this.onNavigate?.(routeId)}
|
||||
></openclaw-sidebar-build-chip>
|
||||
${this.offline
|
||||
? html`<openclaw-tooltip
|
||||
.content=${this.lastError ? redactLoginFailureError(this.lastError) : reconnecting}
|
||||
>
|
||||
${renderOfflineSidebarStatus({
|
||||
queuedOutboxCount: this.queuedOutboxCount,
|
||||
reconnecting,
|
||||
onRetry: () => this.onRetryConnect?.(),
|
||||
})}
|
||||
</openclaw-tooltip>`
|
||||
? renderOfflineSidebarStatus({
|
||||
queuedOutboxCount: this.queuedOutboxCount,
|
||||
reconnecting,
|
||||
title: this.lastError ? redactLoginFailureError(this.lastError) : reconnecting,
|
||||
onRetry: () => this.onRetryConnect?.(),
|
||||
})
|
||||
: nothing}
|
||||
<openclaw-tooltip .content=${t("nav.settings")}>
|
||||
<button
|
||||
|
||||
@@ -4,6 +4,7 @@ import { render } from "lit";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { i18n } from "../i18n/index.ts";
|
||||
import { renderSessionRowBadges, type SessionPlacementState } from "./session-row-badges.ts";
|
||||
import "./tooltip.ts";
|
||||
|
||||
let container: HTMLDivElement;
|
||||
|
||||
@@ -28,6 +29,13 @@ function renderBadges(placementState?: SessionPlacementState, workspaceConflictC
|
||||
);
|
||||
}
|
||||
|
||||
function expectTooltipText(badge: Element | null | undefined, text: string) {
|
||||
expect(badge?.hasAttribute("title")).toBe(false);
|
||||
expect(
|
||||
(badge?.closest("openclaw-tooltip") as (HTMLElement & { content?: string }) | null)?.content,
|
||||
).toBe(text);
|
||||
}
|
||||
|
||||
describe("session row placement badges", () => {
|
||||
it("renders the durable outbox count and stays quiet when empty", () => {
|
||||
render(
|
||||
@@ -40,7 +48,7 @@ describe("session row placement badges", () => {
|
||||
|
||||
const badge = container.querySelector<HTMLElement>(".session-row-badge--queued");
|
||||
expect(badge?.getAttribute("aria-label")).toBe("3 messages queued to send");
|
||||
expect(badge?.getAttribute("title")).toBe("3 messages queued to send");
|
||||
expectTooltipText(badge, "3 messages queued to send");
|
||||
expect(badge?.textContent).toContain("3");
|
||||
expect(badge?.querySelector("svg")).not.toBeNull();
|
||||
|
||||
@@ -72,6 +80,7 @@ describe("session row placement badges", () => {
|
||||
const badge = container.querySelector<HTMLElement>(".session-row-badge--cloud");
|
||||
expect(badge?.dataset.placementState).toBe(placementState);
|
||||
expect(badge?.getAttribute("aria-label")).toBe(`Cloud worker: ${placementState}`);
|
||||
expectTooltipText(badge, `Cloud worker: ${placementState}`);
|
||||
expect(badge?.querySelector("circle")).not.toBeNull();
|
||||
expect(badge?.querySelector("rect")).toBeNull();
|
||||
});
|
||||
@@ -86,6 +95,7 @@ describe("session row placement badges", () => {
|
||||
);
|
||||
|
||||
expect(container.querySelectorAll(".session-row-badge")).toHaveLength(1);
|
||||
expectTooltipText(container.querySelector(".session-row-badge"), "Automation attached");
|
||||
expect(container.querySelector(".session-row-badge--cloud")).toBeNull();
|
||||
});
|
||||
|
||||
@@ -100,7 +110,7 @@ describe("session row placement badges", () => {
|
||||
|
||||
const badge = container.querySelector(".session-row-badge--pull-request");
|
||||
expect(badge?.getAttribute("aria-label")).toBe("#111532 · Open");
|
||||
expect(badge?.getAttribute("title")).toBe("#111532 · Open");
|
||||
expectTooltipText(badge, "#111532 · Open");
|
||||
expect(badge?.getAttribute("data-pull-request-state")).toBe("open");
|
||||
expect(badge?.querySelector("svg")).not.toBeNull();
|
||||
});
|
||||
@@ -122,7 +132,7 @@ describe("session row placement badges", () => {
|
||||
|
||||
const badge = container.querySelector(".session-row-badge--pull-request");
|
||||
expect(badge?.getAttribute("aria-label")).toBe(label);
|
||||
expect(badge?.getAttribute("title")).toBe(label);
|
||||
expectTooltipText(badge, label);
|
||||
expect(badge?.getAttribute("data-pull-request-state")).toBe(state);
|
||||
});
|
||||
|
||||
@@ -137,6 +147,7 @@ describe("session row placement badges", () => {
|
||||
|
||||
const badge = container.querySelector(".session-row-badge--approval");
|
||||
expect(badge?.getAttribute("aria-label")).toBe("Approval needed");
|
||||
expectTooltipText(badge, "Approval needed");
|
||||
expect(badge?.querySelector("svg")).not.toBeNull();
|
||||
});
|
||||
|
||||
@@ -180,11 +191,12 @@ describe("session row placement badges", () => {
|
||||
|
||||
const badge = container.querySelector<HTMLElement>(".session-row-badge--cloud");
|
||||
expect(badge?.dataset.workspaceConflicts).toBe("3");
|
||||
expect(badge?.getAttribute("title")).toBe("Cloud worker: active · 3 workspace conflicts");
|
||||
expectTooltipText(badge, "Cloud worker: active · 3 workspace conflicts");
|
||||
expect(container.querySelectorAll(".session-row-badge")).toHaveLength(1);
|
||||
|
||||
renderBadges("active", 1);
|
||||
expect(container.querySelector(".session-row-badge--cloud")?.getAttribute("title")).toBe(
|
||||
expectTooltipText(
|
||||
container.querySelector(".session-row-badge--cloud"),
|
||||
"Cloud worker: active · 1 workspace conflict",
|
||||
);
|
||||
});
|
||||
@@ -195,7 +207,7 @@ describe("session row placement badges", () => {
|
||||
const badge = container.querySelector<HTMLElement>(".session-row-badge--cloud");
|
||||
expect(badge?.dataset.placementState).toBe("reclaimed");
|
||||
expect(badge?.dataset.workspaceConflicts).toBe("2");
|
||||
expect(badge?.getAttribute("title")).toBe("Cloud worker: reclaimed · 2 workspace conflicts");
|
||||
expectTooltipText(badge, "Cloud worker: reclaimed · 2 workspace conflicts");
|
||||
});
|
||||
|
||||
it("renders descendant conflict attention without claiming a parent placement state", () => {
|
||||
@@ -204,6 +216,6 @@ describe("session row placement badges", () => {
|
||||
const badge = container.querySelector<HTMLElement>(".session-row-badge--cloud");
|
||||
expect(badge?.dataset.placementState).toBeUndefined();
|
||||
expect(badge?.dataset.workspaceConflicts).toBe("2");
|
||||
expect(badge?.getAttribute("title")).toBe("Cloud worker children: 2 workspace conflicts");
|
||||
expectTooltipText(badge, "Cloud worker children: 2 workspace conflicts");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { html, nothing } from "lit";
|
||||
import { html, nothing, type TemplateResult } from "lit";
|
||||
// Deep import on purpose: the protocol barrel carries typebox and every
|
||||
// schema, which must stay out of the Control UI startup bundle.
|
||||
import { isCloudWorkerPlacementState } from "../../../packages/gateway-protocol/src/schema/session-placement-state.js";
|
||||
@@ -37,6 +37,28 @@ function formatSessionPullRequestSummary(summary: SessionCatalogPullRequestSumma
|
||||
return `${numbers} · ${pullRequestStateLabel(summary.state)}`;
|
||||
}
|
||||
|
||||
function renderSessionRowBadge(
|
||||
label: string,
|
||||
icon: TemplateResult,
|
||||
modifier = "",
|
||||
count = 0,
|
||||
pullRequestState?: SessionCatalogPullRequestSummary["state"],
|
||||
placementState?: SessionPlacementState,
|
||||
workspaceConflictCount = 0,
|
||||
) {
|
||||
return html`<openclaw-tooltip .content=${label}>
|
||||
<span
|
||||
class=${`session-row-badge${modifier ? ` ${modifier}` : ""}`}
|
||||
data-pull-request-state=${pullRequestState ?? nothing}
|
||||
data-placement-state=${placementState ?? nothing}
|
||||
data-workspace-conflicts=${workspaceConflictCount ? String(workspaceConflictCount) : nothing}
|
||||
role="img"
|
||||
aria-label=${label}
|
||||
>${icon}${count ? html`<span aria-hidden="true">${count}</span>` : nothing}</span
|
||||
>
|
||||
</openclaw-tooltip>`;
|
||||
}
|
||||
|
||||
export function renderSessionRowBadges(params: {
|
||||
isChild?: boolean;
|
||||
hasAutomation: boolean;
|
||||
@@ -99,54 +121,37 @@ export function renderSessionRowBadges(params: {
|
||||
: "";
|
||||
return html`<span class="session-row-badges">
|
||||
${hasAutomation
|
||||
? html`<span
|
||||
class="session-row-badge"
|
||||
role="img"
|
||||
aria-label=${t("sessionsView.automationAttached")}
|
||||
title=${t("sessionsView.automationAttached")}
|
||||
>${icons.clock}</span
|
||||
>`
|
||||
? renderSessionRowBadge(t("sessionsView.automationAttached"), icons.clock)
|
||||
: nothing}
|
||||
${pullRequestLabel
|
||||
? html`<span
|
||||
class="session-row-badge session-row-badge--pull-request"
|
||||
data-pull-request-state=${pullRequestState ?? nothing}
|
||||
role="img"
|
||||
aria-label=${pullRequestLabel}
|
||||
title=${pullRequestLabel}
|
||||
>${icons.gitPullRequest}</span
|
||||
>`
|
||||
? renderSessionRowBadge(
|
||||
pullRequestLabel,
|
||||
icons.gitPullRequest,
|
||||
"session-row-badge--pull-request",
|
||||
0,
|
||||
pullRequestState,
|
||||
)
|
||||
: nothing}
|
||||
${params.hasApproval
|
||||
? html`<span
|
||||
class="session-row-badge session-row-badge--approval"
|
||||
role="img"
|
||||
aria-label=${t("sessionsView.approvalNeeded")}
|
||||
title=${t("sessionsView.approvalNeeded")}
|
||||
>${icons.alertTriangle}</span
|
||||
>`
|
||||
? renderSessionRowBadge(
|
||||
t("sessionsView.approvalNeeded"),
|
||||
icons.alertTriangle,
|
||||
"session-row-badge--approval",
|
||||
)
|
||||
: nothing}
|
||||
${outboxCount > 0
|
||||
? html`<span
|
||||
class="session-row-badge session-row-badge--queued"
|
||||
role="img"
|
||||
aria-label=${outboxLabel}
|
||||
title=${outboxLabel}
|
||||
>${icons.clock}<span aria-hidden="true">${outboxCount}</span></span
|
||||
>`
|
||||
? renderSessionRowBadge(outboxLabel, icons.clock, "session-row-badge--queued", outboxCount)
|
||||
: nothing}
|
||||
${displayedPlacementState || hasWorkspaceConflict
|
||||
? html`<span
|
||||
class="session-row-badge session-row-badge--cloud"
|
||||
data-placement-state=${displayedPlacementState ?? nothing}
|
||||
data-workspace-conflicts=${hasWorkspaceConflict
|
||||
? String(workspaceConflictCount)
|
||||
: nothing}
|
||||
role="img"
|
||||
aria-label=${cloudLabel}
|
||||
title=${cloudLabel}
|
||||
>${icons.globe}</span
|
||||
>`
|
||||
? renderSessionRowBadge(
|
||||
cloudLabel,
|
||||
icons.globe,
|
||||
"session-row-badge--cloud",
|
||||
0,
|
||||
undefined,
|
||||
displayedPlacementState,
|
||||
hasWorkspaceConflict ? workspaceConflictCount : 0,
|
||||
)
|
||||
: nothing}
|
||||
</span>`;
|
||||
}
|
||||
@@ -160,17 +165,20 @@ export function renderOfflineSidebarStatus(props: {
|
||||
const offline = t("common.offline");
|
||||
const count = props.queuedOutboxCount;
|
||||
const queued = count ? t("connection.queuedCount", { count: String(count) }) : null;
|
||||
return html`<button
|
||||
type="button"
|
||||
class="sidebar-footer-bar__status"
|
||||
aria-live="polite"
|
||||
aria-label=${`${offline} — ${t("connection.retryNow")}${queued ? ` — ${queued}` : ""}`}
|
||||
title=${props.title ?? nothing}
|
||||
@click=${props.onRetry}
|
||||
>
|
||||
<span class="sidebar-footer-bar__status-dot" aria-hidden="true"></span>${offline}<span
|
||||
class="sidebar-footer-bar__status-detail"
|
||||
>· ${props.reconnecting}</span
|
||||
>${queued ? html`<span class="sidebar-footer-bar__status-detail">· ${queued}</span>` : nothing}
|
||||
</button>`;
|
||||
return html`<openclaw-tooltip .content=${props.title ?? ""}>
|
||||
<button
|
||||
type="button"
|
||||
class="sidebar-footer-bar__status"
|
||||
aria-live="polite"
|
||||
aria-label=${`${offline} — ${t("connection.retryNow")}${queued ? ` — ${queued}` : ""}`}
|
||||
@click=${props.onRetry}
|
||||
>
|
||||
<span class="sidebar-footer-bar__status-dot" aria-hidden="true"></span>${offline}<span
|
||||
class="sidebar-footer-bar__status-detail"
|
||||
>· ${props.reconnecting}</span
|
||||
>${queued
|
||||
? html`<span class="sidebar-footer-bar__status-detail">· ${queued}</span>`
|
||||
: nothing}
|
||||
</button>
|
||||
</openclaw-tooltip>`;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { i18n } from "../i18n/index.ts";
|
||||
import { pt_BR } from "../i18n/locales/pt-BR.ts";
|
||||
import { renderSettingsSidebar } from "./settings-sidebar.ts";
|
||||
import "./tooltip.ts";
|
||||
|
||||
let container: HTMLDivElement;
|
||||
|
||||
@@ -363,7 +364,10 @@ describe("settings sidebar search", () => {
|
||||
|
||||
renderSidebar(true, "connection refused?token=settings-secret", 3);
|
||||
const button = container.querySelector<HTMLButtonElement>(".sidebar-footer-bar__status");
|
||||
expect(button?.title).toBe("connection refused?[redacted-credential]");
|
||||
expect(button?.hasAttribute("title")).toBe(false);
|
||||
expect(
|
||||
(button?.closest("openclaw-tooltip") as (HTMLElement & { content?: string }) | null)?.content,
|
||||
).toBe("connection refused?[redacted-credential]");
|
||||
expect(button?.textContent).toContain("3 queued");
|
||||
expect(button?.getAttribute("aria-label")).toBe("Offline — Retry now — 3 queued");
|
||||
button?.click();
|
||||
|
||||
@@ -66,12 +66,13 @@ class SidebarAgentCard extends OpenClawLightDomContentsElement {
|
||||
: nothing}
|
||||
</span>
|
||||
${this.approvalCount > 0
|
||||
? html`<span
|
||||
class="sidebar-agent-approval-count sidebar-agent-card__approval-count"
|
||||
aria-label=${approvalLabel}
|
||||
title=${approvalLabel}
|
||||
>${this.approvalCount}</span
|
||||
>`
|
||||
? html`<openclaw-tooltip .content=${approvalLabel}>
|
||||
<span
|
||||
class="sidebar-agent-approval-count sidebar-agent-card__approval-count"
|
||||
aria-label=${approvalLabel}
|
||||
>${this.approvalCount}</span
|
||||
>
|
||||
</openclaw-tooltip>`
|
||||
: nothing}
|
||||
${this.menuUnread && !this.menuOpen
|
||||
? html`<span
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
buildSidebarAttentionItems,
|
||||
type SidebarAttentionItem,
|
||||
} from "./sidebar-attention-items.ts";
|
||||
import "./tooltip.ts";
|
||||
|
||||
// Reloads are connection-scoped; a visibility change only refetches after the
|
||||
// snapshot is older than this, so tab switches stay free of request bursts.
|
||||
@@ -222,24 +223,28 @@ class SidebarAttention extends OpenClawLightDomContentsElement {
|
||||
${items.map(
|
||||
(item) => html`
|
||||
<div class="sidebar-attention__item sidebar-attention__item--${item.severity}">
|
||||
<button
|
||||
type="button"
|
||||
class="sidebar-attention__open"
|
||||
title=${item.label}
|
||||
@click=${() => this.open(item)}
|
||||
>
|
||||
<span class="sidebar-attention__icon" aria-hidden="true">${icons[item.icon]}</span>
|
||||
<span class="sidebar-attention__label">${item.label}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="sidebar-attention__dismiss"
|
||||
title=${t("common.dismiss")}
|
||||
aria-label=${t("common.dismiss")}
|
||||
@click=${() => this.dismiss(item)}
|
||||
>
|
||||
${icons.x}
|
||||
</button>
|
||||
<openclaw-tooltip .content=${item.label}>
|
||||
<button
|
||||
type="button"
|
||||
class="sidebar-attention__open"
|
||||
@click=${() => this.open(item)}
|
||||
>
|
||||
<span class="sidebar-attention__icon" aria-hidden="true"
|
||||
>${icons[item.icon]}</span
|
||||
>
|
||||
<span class="sidebar-attention__label">${item.label}</span>
|
||||
</button>
|
||||
</openclaw-tooltip>
|
||||
<openclaw-tooltip .content=${t("common.dismiss")}>
|
||||
<button
|
||||
type="button"
|
||||
class="sidebar-attention__dismiss"
|
||||
aria-label=${t("common.dismiss")}
|
||||
@click=${() => this.dismiss(item)}
|
||||
>
|
||||
${icons.x}
|
||||
</button>
|
||||
</openclaw-tooltip>
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
// @vitest-environment node
|
||||
/* @vitest-environment jsdom */
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createStorageMock } from "../../test-helpers/storage.ts";
|
||||
import {
|
||||
resolveStoredChatOutboxScope,
|
||||
storedChatOutboxScopeKey,
|
||||
subscribeStoredChatOutboxChanges,
|
||||
summarizeStoredChatOutboxes,
|
||||
} from "./outbox-store.ts";
|
||||
|
||||
@@ -12,10 +13,44 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("stored outbox summaries", () => {
|
||||
it("bridges matching cross-tab storage changes until the last subscriber leaves", () => {
|
||||
const addEventListener = vi.spyOn(window, "addEventListener");
|
||||
const removeEventListener = vi.spyOn(window, "removeEventListener");
|
||||
const firstListener = vi.fn();
|
||||
const secondListener = vi.fn();
|
||||
const unsubscribeFirst = subscribeStoredChatOutboxChanges(firstListener);
|
||||
const unsubscribeSecond = subscribeStoredChatOutboxChanges(secondListener);
|
||||
|
||||
expect(addEventListener).toHaveBeenCalledWith("storage", expect.any(Function));
|
||||
|
||||
window.dispatchEvent(
|
||||
new StorageEvent("storage", { key: "openclaw.control.chatComposer.v2:gateway" }),
|
||||
);
|
||||
expect(firstListener).toHaveBeenCalledTimes(1);
|
||||
expect(secondListener).toHaveBeenCalledTimes(1);
|
||||
|
||||
window.dispatchEvent(new StorageEvent("storage", { key: "openclaw.control.settings.v1" }));
|
||||
expect(firstListener).toHaveBeenCalledTimes(1);
|
||||
expect(secondListener).toHaveBeenCalledTimes(1);
|
||||
|
||||
window.dispatchEvent(
|
||||
new StorageEvent("storage", { key: "openclaw.control.chatComposer.v1:gateway" }),
|
||||
);
|
||||
expect(firstListener).toHaveBeenCalledTimes(2);
|
||||
expect(secondListener).toHaveBeenCalledTimes(2);
|
||||
|
||||
unsubscribeFirst();
|
||||
expect(removeEventListener).not.toHaveBeenCalledWith("storage", expect.any(Function));
|
||||
|
||||
unsubscribeSecond();
|
||||
expect(removeEventListener).toHaveBeenCalledWith("storage", expect.any(Function));
|
||||
});
|
||||
|
||||
it("routes shipped bare-main rows to the known default agent", () => {
|
||||
const gatewayUrl = "ws://gateway.test/control";
|
||||
const legacyKey = `openclaw.control.chatComposer.v1:${encodeURIComponent(gatewayUrl)}`;
|
||||
|
||||
@@ -13,6 +13,7 @@ const LEGACY_STORAGE_KEY_PREFIX = "openclaw.control.chatComposer.v1:";
|
||||
const STORAGE_KEY_PREFIX = "openclaw.control.chatComposer.v2:";
|
||||
export const UNRESOLVED_GLOBAL_AGENT_SCOPE = "@unresolved";
|
||||
const storedChatOutboxChangeListeners = new Set<() => void>();
|
||||
let storageChangeListenerInstalled = false;
|
||||
|
||||
export type ChatComposerScope = {
|
||||
settings?: { gatewayUrl?: string | null };
|
||||
@@ -57,7 +58,21 @@ const storedMainAliasByStorage = new WeakMap<
|
||||
|
||||
export function subscribeStoredChatOutboxChanges(listener: () => void): () => void {
|
||||
storedChatOutboxChangeListeners.add(listener);
|
||||
return () => storedChatOutboxChangeListeners.delete(listener);
|
||||
if (!storageChangeListenerInstalled && typeof window !== "undefined") {
|
||||
storageChangeListenerInstalled = true;
|
||||
window.addEventListener("storage", handleStoredChatOutboxStorageChange);
|
||||
}
|
||||
return () => {
|
||||
storedChatOutboxChangeListeners.delete(listener);
|
||||
if (
|
||||
storageChangeListenerInstalled &&
|
||||
storedChatOutboxChangeListeners.size === 0 &&
|
||||
typeof window !== "undefined"
|
||||
) {
|
||||
storageChangeListenerInstalled = false;
|
||||
window.removeEventListener("storage", handleStoredChatOutboxStorageChange);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function notifyStoredChatOutboxChanges(): void {
|
||||
@@ -70,6 +85,15 @@ export function notifyStoredChatOutboxChanges(): void {
|
||||
}
|
||||
}
|
||||
|
||||
function handleStoredChatOutboxStorageChange(event: StorageEvent): void {
|
||||
if (
|
||||
event.key?.startsWith(STORAGE_KEY_PREFIX) ||
|
||||
event.key?.startsWith(LEGACY_STORAGE_KEY_PREFIX)
|
||||
) {
|
||||
notifyStoredChatOutboxChanges();
|
||||
}
|
||||
}
|
||||
|
||||
export function storageTargetForGateway(
|
||||
gatewayUrl: string | null | undefined,
|
||||
): ComposerStorageTarget {
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createIdleImport } from "./idle-import.ts";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.stubGlobal(
|
||||
"requestIdleCallback",
|
||||
vi.fn((callback: IdleRequestCallback) =>
|
||||
window.setTimeout(() => callback({ didTimeout: false, timeRemaining: () => 50 }), 0),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("createIdleImport", () => {
|
||||
it("waits for the idle schedule before importing", async () => {
|
||||
const importModule = vi.fn(async () => "loaded");
|
||||
const onLoaded = vi.fn();
|
||||
const idleImport = createIdleImport(importModule, onLoaded);
|
||||
|
||||
idleImport.schedule();
|
||||
|
||||
expect(importModule).not.toHaveBeenCalled();
|
||||
expect(requestIdleCallback).toHaveBeenCalledWith(expect.any(Function), { timeout: 3000 });
|
||||
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(importModule).toHaveBeenCalledTimes(1);
|
||||
expect(onLoaded).toHaveBeenCalledWith("loaded");
|
||||
});
|
||||
|
||||
it("clears a failed import and re-arms it when the browser comes online", async () => {
|
||||
vi.spyOn(navigator, "onLine", "get").mockReturnValue(false);
|
||||
const importModule = vi
|
||||
.fn<() => Promise<string>>()
|
||||
.mockRejectedValueOnce(new Error("offline"))
|
||||
.mockResolvedValue("loaded");
|
||||
const onLoaded = vi.fn();
|
||||
const idleImport = createIdleImport(importModule, onLoaded);
|
||||
|
||||
idleImport.schedule();
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(importModule).toHaveBeenCalledTimes(1);
|
||||
|
||||
window.dispatchEvent(new Event("online"));
|
||||
await vi.waitFor(() => expect(onLoaded).toHaveBeenCalledWith("loaded"));
|
||||
|
||||
expect(importModule).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("retries one failed import while the browser remains online", async () => {
|
||||
const importModule = vi
|
||||
.fn<() => Promise<string>>()
|
||||
.mockRejectedValueOnce(new Error("transient"))
|
||||
.mockResolvedValue("loaded");
|
||||
const onLoaded = vi.fn();
|
||||
const idleImport = createIdleImport(importModule, onLoaded);
|
||||
|
||||
idleImport.schedule();
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(importModule).toHaveBeenCalledTimes(2);
|
||||
expect(onLoaded).toHaveBeenCalledWith("loaded");
|
||||
});
|
||||
|
||||
it("removes its online retry listener when disposed", async () => {
|
||||
const importModule = vi.fn<() => Promise<string>>().mockRejectedValue(new Error("offline"));
|
||||
const removeEventListener = vi.spyOn(window, "removeEventListener");
|
||||
const idleImport = createIdleImport(importModule);
|
||||
|
||||
await expect(idleImport.load()).rejects.toThrow("offline");
|
||||
idleImport.dispose();
|
||||
window.dispatchEvent(new Event("online"));
|
||||
await Promise.resolve();
|
||||
|
||||
expect(removeEventListener).toHaveBeenCalledWith("online", expect.any(Function));
|
||||
expect(importModule).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("shares and retains a successful module promise", async () => {
|
||||
const module = { ready: true };
|
||||
const importModule = vi.fn(async () => module);
|
||||
const onLoaded = vi.fn();
|
||||
const idleImport = createIdleImport(importModule, onLoaded);
|
||||
|
||||
const first = idleImport.load();
|
||||
const second = idleImport.load();
|
||||
|
||||
await expect(first).resolves.toBe(module);
|
||||
await expect(second).resolves.toBe(module);
|
||||
await expect(idleImport.load()).resolves.toBe(module);
|
||||
expect(importModule).toHaveBeenCalledTimes(1);
|
||||
expect(onLoaded).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
export function createIdleImport<T>(importModule: () => Promise<T>, onLoaded?: (value: T) => void) {
|
||||
let moduleLoad: Promise<T> | null = null;
|
||||
let active = false;
|
||||
let onlineRetryAttempted = false;
|
||||
|
||||
const run = (): Promise<T> => {
|
||||
moduleLoad ??= importModule()
|
||||
.then((value) => {
|
||||
window.removeEventListener("online", start);
|
||||
onLoaded?.(value);
|
||||
return value;
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
// A failed chunk fetch must not pin a rejected promise forever.
|
||||
moduleLoad = null;
|
||||
if (!active) {
|
||||
throw error;
|
||||
}
|
||||
window.addEventListener("online", start, { once: true });
|
||||
if (navigator.onLine && !onlineRetryAttempted) {
|
||||
onlineRetryAttempted = true;
|
||||
scheduleIdle();
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
return moduleLoad;
|
||||
};
|
||||
|
||||
const start = () => active && void run().catch(() => undefined);
|
||||
|
||||
const scheduleIdle = () => {
|
||||
if (moduleLoad) {
|
||||
return;
|
||||
}
|
||||
if ("requestIdleCallback" in window) {
|
||||
requestIdleCallback(start, { timeout: 3000 });
|
||||
} else {
|
||||
setTimeout(start, 1500);
|
||||
}
|
||||
};
|
||||
|
||||
const activate = () => {
|
||||
active = true;
|
||||
onlineRetryAttempted = false;
|
||||
};
|
||||
|
||||
const dispose = () => {
|
||||
active = false;
|
||||
window.removeEventListener("online", start);
|
||||
};
|
||||
|
||||
return {
|
||||
schedule: (): void => {
|
||||
activate();
|
||||
scheduleIdle();
|
||||
},
|
||||
load: (): Promise<T> => {
|
||||
activate();
|
||||
return run();
|
||||
},
|
||||
dispose,
|
||||
};
|
||||
}
|
||||
@@ -180,6 +180,41 @@ describe("AppSidebar session attention", () => {
|
||||
expect(sidebar.textContent).not.toContain("Run failed:");
|
||||
});
|
||||
|
||||
it("uses shared tooltips for Home and agent approval badges", async () => {
|
||||
const mainKey = "agent:main:main";
|
||||
const approval = {
|
||||
id: "approval-main",
|
||||
kind: "exec",
|
||||
request: { command: "git status", sessionKey: mainKey },
|
||||
createdAtMs: Date.now(),
|
||||
expiresAtMs: Date.now() + 60_000,
|
||||
} satisfies ExecApprovalRequest;
|
||||
const { sidebar } = await mountSidebar(
|
||||
createGateway({} as GatewayBrowserClient),
|
||||
createSessionsHarness("main", [mainKey]).sessions,
|
||||
"panel",
|
||||
null,
|
||||
[approval],
|
||||
);
|
||||
|
||||
const homeBadge = sidebar.querySelector(".nav-item--home .session-approval-badge");
|
||||
expect(homeBadge?.getAttribute("aria-label")).toBe("Approval needed");
|
||||
expect(homeBadge?.hasAttribute("title")).toBe(false);
|
||||
expect(
|
||||
(homeBadge?.closest("openclaw-tooltip") as (HTMLElement & { content?: string }) | null)
|
||||
?.content,
|
||||
).toBe("Approval needed");
|
||||
|
||||
const agentBadge = sidebar.querySelector(".sidebar-agent-card__approval-count");
|
||||
const agentLabel = agentBadge?.getAttribute("aria-label");
|
||||
expect(agentLabel).toBeTruthy();
|
||||
expect(agentBadge?.hasAttribute("title")).toBe(false);
|
||||
expect(
|
||||
(agentBadge?.closest("openclaw-tooltip") as (HTMLElement & { content?: string }) | null)
|
||||
?.content,
|
||||
).toBe(agentLabel);
|
||||
});
|
||||
|
||||
it("shows an error icon and reason for an unread failure", async () => {
|
||||
const sessionsHarness = createSessionsHarness("main", [sessionKey]);
|
||||
setRows(sessionsHarness, [failedRow()]);
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { GatewayBrowserClient } from "../../api/gateway.ts";
|
||||
import type { AgentsListResult } from "../../api/types.ts";
|
||||
import {
|
||||
clearSessionBoardAvailability,
|
||||
recordSessionBoardAvailability,
|
||||
} from "../../lib/board/provider.ts";
|
||||
import {
|
||||
createGateway,
|
||||
createGatewayHarness,
|
||||
@@ -413,6 +417,34 @@ describe("AppSidebar agent chip", () => {
|
||||
expect(sidebar.querySelector(".sidebar-agent-card__subtitle")?.textContent).toContain(
|
||||
"Working",
|
||||
);
|
||||
const spinner = sidebar.querySelector(".nav-item--home .session-run-spinner");
|
||||
expect(spinner?.hasAttribute("title")).toBe(false);
|
||||
expect(
|
||||
(spinner?.closest("openclaw-tooltip") as (HTMLElement & { content?: string }) | null)
|
||||
?.content,
|
||||
).toBe("Active run");
|
||||
});
|
||||
|
||||
it("uses the shared tooltip for the Home dashboard glyph", async () => {
|
||||
const mainKey = "agent:main:main";
|
||||
const gateway = createGateway({} as GatewayBrowserClient);
|
||||
const { sidebar } = await mountSidebar(gateway, createSessions("main", [mainKey]));
|
||||
|
||||
try {
|
||||
recordSessionBoardAvailability(mainKey, true);
|
||||
sidebar.requestUpdate();
|
||||
await sidebar.updateComplete;
|
||||
|
||||
const glyph = sidebar.querySelector(".nav-item--home .sidebar-board-glyph");
|
||||
expect(glyph?.getAttribute("aria-label")).toBe("Dashboard available");
|
||||
expect(glyph?.hasAttribute("title")).toBe(false);
|
||||
expect(
|
||||
(glyph?.closest("openclaw-tooltip") as (HTMLElement & { content?: string }) | null)
|
||||
?.content,
|
||||
).toBe("Dashboard available");
|
||||
} finally {
|
||||
clearSessionBoardAvailability();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the sessions list flat for the selected agent and flags other-agent unread", async () => {
|
||||
|
||||
@@ -225,9 +225,12 @@ describe("AppSidebar session catalog pagination", () => {
|
||||
expect(local?.querySelector(".sidebar-session-catalog-host__head")).toBeNull();
|
||||
expect(local?.textContent).not.toContain("Gateway Mac");
|
||||
expect(local?.textContent).toContain("Local plan");
|
||||
expect(local?.querySelector(".session-row-badge--pull-request")?.getAttribute("title")).toBe(
|
||||
"#111751, #111772 · Merged",
|
||||
);
|
||||
const pullRequestBadge = local?.querySelector(".session-row-badge--pull-request");
|
||||
expect(pullRequestBadge?.hasAttribute("title")).toBe(false);
|
||||
expect(
|
||||
(pullRequestBadge?.closest("openclaw-tooltip") as (HTMLElement & { content?: string }) | null)
|
||||
?.content,
|
||||
).toBe("#111751, #111772 · Merged");
|
||||
expect(local?.textContent).not.toContain("Remote review");
|
||||
expect(remote?.textContent).toContain("Build Node");
|
||||
expect(remote?.textContent).toContain("Remote review");
|
||||
@@ -302,8 +305,11 @@ describe("AppSidebar session catalog pagination", () => {
|
||||
`[data-session-key="${backingSessionKey}"]`,
|
||||
);
|
||||
expect(linkedRow?.getAttribute("draggable")).toBe("true");
|
||||
const pullRequestBadge = linkedRow?.querySelector(".session-row-badge--pull-request");
|
||||
expect(pullRequestBadge?.hasAttribute("title")).toBe(false);
|
||||
expect(
|
||||
linkedRow?.querySelector(".session-row-badge--pull-request")?.getAttribute("title"),
|
||||
(pullRequestBadge?.closest("openclaw-tooltip") as (HTMLElement & { content?: string }) | null)
|
||||
?.content,
|
||||
).toBe("#107302 · Draft");
|
||||
expect(linkedRow?.querySelector('[data-sidebar-session-pin="true"]')).not.toBeNull();
|
||||
expect(linkedRow?.querySelector('[data-session-menu="true"]')).not.toBeNull();
|
||||
|
||||
@@ -220,7 +220,11 @@ describe("AppSidebar agent chip", () => {
|
||||
);
|
||||
expect(parentBadge?.dataset.workspaceConflicts).toBe("2");
|
||||
expect(parentBadge?.dataset.placementState).toBeUndefined();
|
||||
expect(parentBadge?.getAttribute("title")).toBe("Cloud worker children: 2 workspace conflicts");
|
||||
expect(parentBadge?.hasAttribute("title")).toBe(false);
|
||||
expect(
|
||||
(parentBadge?.closest("openclaw-tooltip") as (HTMLElement & { content?: string }) | null)
|
||||
?.content,
|
||||
).toBe("Cloud worker children: 2 workspace conflicts");
|
||||
});
|
||||
|
||||
it("loads every child-session page before marking a parent complete", async () => {
|
||||
|
||||
Reference in New Issue
Block a user