feat(ui): surface queued-outbox counts in composer hint, session rows, and offline footer (#112649)

* feat(ui): surface queued-outbox counts in composer hint, session rows, and offline footer

Follow-up to the offline-state unification: queued sends were mechanically
safe (durable outbox with reconnect replay) but only visible inside the
open session's thread.

- Composer offline hint now includes the visible session's queued count.
- Session rows show a clock badge with the per-scope outbox count,
  independent of connection state (covers waiting-idle and failed too),
  with alias-safe scope resolution so agent-main never double-counts.
- The offline footer button appends the aggregate ("· N queued"); the
  connected state stays completely silent.
- One narrow subscription seam (subscribeStoredChatOutboxChanges) added
  in composer-persistence; no send/drain logic touched.

* perf(ui): keep the startup bundle under budget with a lean outbox read module

The queued-count feature statically imported composer-persistence from
startup modules, hoisting the chat page's persistence machinery into the
startup chunk and breaking the Control UI startup JS gzip budget
(319.6 KiB > 314.0 KiB limit).

Split ownership instead of gaming the budget: a lean read/subscribe
module (ui/src/lib/chat/outbox-store.ts + codec/draft-state) serves
startup consumers (app-host, sidebar), while writes, migrations, and
drain stay in the lazy chat chunk (composer-outbox-store/composer-storage);
composer-persistence keeps its export surface for chat callers. Startup
is back to 313.8 KiB gzip at 12 requests with no chunking-config changes.

Also fixes an autoreview finding in the new summary: legacy bare-main
outbox rows now resolve through session defaults (online) or the
persisted mainAlias (offline reload) instead of trusting the row's stale
embedded agent id, so badge counts key to the same scope the sidebar
resolves. The shared footer status renderer is deduplicated into
session-row-badges.

* fix(ui): correct type-only import and const tuple in outbox split

* perf(ui): idle-load the outbox summary so startup carries no outbox code

The lean outbox read module still cost ~2.8 KiB of startup gzip against
1.5 KiB of budget headroom. Follow the sidebar chrome pattern
(lobster-pet/facepile): app-host idle-loads outbox-store, subscribes on
arrival, and passes the sidebar a resolver callback instead of letting
startup modules import scope resolution. Badges and counts hydrate
moments after load; before that the summary is empty by design.

Failed chunk loads recover on browser online events and, because chunks
are usually served by the gateway itself, on gateway reconnect — the
exact moment the offline badges become relevant again.

Raise the initial-graph packing ceiling 448->512 KiB: the grown core
graph split at the old boundary into an extra chunk, costing ~1.9 KiB of
startup gzip to compression-context resets (same documented tradeoff as
the earlier 400->448 bump). Startup lands at 313.5 KiB gzip / 9 requests,
matching the origin/main baseline, limit 315.0.
This commit is contained in:
Peter Steinberger
2026-07-22 10:53:22 -07:00
committed by GitHub
parent a3a08a6db0
commit 85fda04df7
29 changed files with 1708 additions and 951 deletions
+4 -3
View File
@@ -83,9 +83,10 @@ export const controlUiCodeSplitting = {
normalizeModuleId(id).includes("/ui/src/") ? "control-ui-core" : "control-ui-foundation",
tags: ["$initial"] as ["$initial"],
priority: 10,
// 448 KiB packs the core graph into fewer chunks; the previous 400 KiB
// boundary split one core chunk in two, costing ~1.4 KiB startup gzip.
maxSize: 448 * 1024,
// 512 KiB packs the grown core graph into fewer chunks; the previous
// 448 KiB boundary split one core chunk in two, costing ~1.9 KiB startup
// gzip (same tradeoff as the earlier 400->448 bump).
maxSize: 512 * 1024,
},
],
};
+105 -1
View File
@@ -53,8 +53,8 @@ import { copyToClipboard } from "../lib/clipboard.ts";
import { isGatewayMethodAdvertised } from "../lib/gateway-methods.ts";
import { isWorkboardEnabledInConfigSnapshot } from "../lib/plugin-activation.ts";
import { searchForSession } from "../lib/sessions/index.ts";
import { isTerminalAvailable } from "../lib/terminal-availability.ts";
import "../lib/toast.ts";
import { isTerminalAvailable } from "../lib/terminal-availability.ts";
import { OpenClawLightDomElement } from "../lit/openclaw-element.ts";
import { SubscriptionsController } from "../lit/subscriptions-controller.ts";
import { findSettingsSearchBlocks } from "../pages/config/settings-search.ts";
@@ -123,6 +123,29 @@ type AppSidebarElement = HTMLElement & {
// on every shell render.
const ROUTE_IDS_WITHOUT_WORKBOARD = APP_ROUTE_IDS.filter((routeId) => routeId !== "workboard");
const AGENT_ROSTER_REFRESH_DEBOUNCE_MS = 100;
const EMPTY_OUTBOX_COUNT_FOR_SESSION = () => 0;
type StoredOutboxScopeHost = {
settings: { gatewayUrl?: string | null };
assistantAgentId?: string | null;
agentsList?: { defaultId?: string | null; mainKey?: string | null } | null;
hello?: { snapshot?: unknown } | null;
};
type OutboxStoreRuntime = {
summarizeStoredChatOutboxes: (state: StoredOutboxScopeHost) => {
countsByScope: ReadonlyMap<string, number>;
total: number;
};
resolveStoredChatOutboxScope: (
state: StoredOutboxScopeHost,
sessionKey: string,
) => { sessionKey: string; agentId?: string };
storedChatOutboxScopeKey: (scope: { sessionKey: string; agentId?: string }) => string;
subscribeStoredChatOutboxChanges: (listener: () => void) => () => void;
};
let outboxStoreModuleLoad: Promise<OutboxStoreRuntime> | null = null;
function diffAgentRoster(
previous: readonly GatewayAgentRow[],
@@ -507,6 +530,9 @@ class OpenClawShell extends OpenClawLightDomElement {
private sidebarWorkboardRuntimeLoad: Promise<SidebarWorkboardRuntimeFactory> | null = null;
private sidebarWorkboardEpoch = 0;
private agentRosterRefreshTimer: ReturnType<typeof globalThis.setTimeout> | null = null;
private outboxStoreRuntime: OutboxStoreRuntime | null = null;
private outboxStoreUnsubscribe: (() => void) | null = null;
private outboxStoreRetryAttempted = false;
private lastNativeNavState: NativeNavState | undefined;
private didConsiderNativeRouteRestore = false;
private pendingNativeNewSession = false;
@@ -631,6 +657,7 @@ class OpenClawShell extends OpenClawLightDomElement {
override connectedCallback() {
super.connectedCallback();
this.scheduleOutboxStoreLoad();
this.nativeHistoryState = readNativeHistoryState();
this.addEventListener(COMMAND_PALETTE_TARGET_EVENT, this.handleCommandPaletteTarget);
window.addEventListener(COMMAND_PALETTE_OPEN_EVENT, this.openPalette);
@@ -678,11 +705,57 @@ 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.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 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;
@@ -1257,6 +1330,12 @@ class OpenClawShell extends OpenClawLightDomElement {
this.ensureAgentsList(snapshot);
this.ensureRuntimeConfig(snapshot);
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();
}
}
private syncSidebarWorkboard() {
@@ -1470,6 +1549,28 @@ class OpenClawShell extends OpenClawLightDomElement {
return nothing;
}
const gatewaySnapshot = context.gateway.snapshot;
const outboxScopeHost = {
settings: { gatewayUrl: context.gateway.connection.gatewayUrl },
assistantAgentId: gatewaySnapshot.assistantAgentId,
agentsList: context.agents.state.agentsList,
hello: gatewaySnapshot.hello,
};
const outboxStoreRuntime = this.outboxStoreRuntime;
const storedOutboxes = outboxStoreRuntime
? outboxStoreRuntime.summarizeStoredChatOutboxes(outboxScopeHost)
: null;
const outboxCountForSession = outboxStoreRuntime
? (sessionKey: string) => {
const scope = outboxStoreRuntime.resolveStoredChatOutboxScope(
outboxScopeHost,
sessionKey,
);
return (
storedOutboxes?.countsByScope.get(outboxStoreRuntime.storedChatOutboxScopeKey(scope)) ??
0
);
}
: EMPTY_OUTBOX_COUNT_FOR_SESSION;
const navigationSnapshot = context.navigation.snapshot;
const overlaySnapshot = context.overlays.snapshot;
const terminalAvailable = isTerminalAvailable(
@@ -1597,6 +1698,7 @@ class OpenClawShell extends OpenClawLightDomElement {
activeSearch: this.routeState.location?.search ?? "",
activeHash: this.routeState.location?.hash ?? "",
offline: gatewaySnapshot.offlineStable,
queuedOutboxCount: storedOutboxes?.total ?? 0,
lastError: gatewaySnapshot.lastError,
version:
context.config.current.serverVersion ??
@@ -1628,6 +1730,8 @@ class OpenClawShell extends OpenClawLightDomElement {
.sessionKey=${this.activeSessionKey}
.connected=${gatewaySnapshot.connected}
.offline=${gatewaySnapshot.offlineStable}
.outboxCountForSession=${outboxCountForSession}
.queuedOutboxCount=${storedOutboxes?.total ?? 0}
.lastError=${gatewaySnapshot.lastError}
.terminalAvailable=${terminalAvailable}
.catalogOpenTarget=${normalizeCatalogOpenTarget(uiSettings.catalogOpenTarget)}
+1 -1
View File
@@ -47,7 +47,7 @@ describe("Control UI build chunking", () => {
expect(controlUiCodeSplitting.includeDependenciesRecursively).toBe(false);
expect(controlUiCodeSplitting.groups[1]).toMatchObject({
tags: ["$initial"],
maxSize: 448 * 1024,
maxSize: 512 * 1024,
});
});
+2
View File
@@ -23,6 +23,8 @@ export abstract class AppSidebarBase extends OpenClawLightDomContentsElement {
@property({ attribute: false }) enabledRouteIds?: readonly NavigationRouteId[];
@property({ attribute: false }) connected = false;
@property({ attribute: false }) offline = false;
@property({ attribute: false }) outboxCountForSession: (sessionKey: string) => number = () => 0;
@property({ attribute: false }) queuedOutboxCount = 0;
@property({ attribute: false }) lastError: string | null = null;
@property({ attribute: false }) terminalAvailable = false;
@property({ attribute: false }) catalogOpenTarget: CatalogOpenTarget = "viewer";
@@ -121,6 +121,10 @@ export abstract class AppSidebarSessionNavigationElement extends AppSidebarSessi
return this.sessionKey.trim() || this.context?.gateway.snapshot.sessionKey.trim() || "";
}
protected outboxCountForSessionKey(sessionKey: string): number {
return this.outboxCountForSession(sessionKey);
}
protected getSessionNavigationState() {
const context = this.context;
const routeSessionKey = this.getRouteSessionKey();
@@ -183,6 +187,7 @@ export abstract class AppSidebarSessionNavigationElement extends AppSidebarSessi
cloudWorkerActive: isStoppableCloudWorkerPlacement(row.placement),
hasAutomation: row.hasAutomation === true,
pullRequest: context?.sessions.pullRequestSummary(row.key),
outboxCount: this.outboxCountForSessionKey(row.key),
unread: row.archived !== true && row.unread === true,
lastReadAt: row.lastReadAt,
attention:
@@ -78,6 +78,7 @@ export type SidebarRecentSession = {
cloudWorkerActive: boolean;
hasAutomation: boolean;
pullRequest?: SessionCatalogPullRequestSummary;
outboxCount?: number;
unread: boolean;
lastReadAt?: number;
attention: SidebarSessionAttention;
+1
View File
@@ -14,6 +14,7 @@ import "../test-helpers/app-sidebar-cases/child-sessions.ts";
import "../test-helpers/app-sidebar-cases/group-mutations.ts";
import "../test-helpers/app-sidebar-cases/interactions.ts";
import "../test-helpers/app-sidebar-cases/narration.ts";
import "../test-helpers/app-sidebar-cases/outbox-badges.ts";
import "../test-helpers/app-sidebar-cases/pull-request-state.ts";
import "../test-helpers/app-sidebar-cases/sidebar-scroll.ts";
import "../test-helpers/app-sidebar-cases/sessions.ts";
+9 -12
View File
@@ -46,6 +46,7 @@ import {
type LobsterLogoVisitDetail,
} from "./lobster-pet-contract.ts";
import { redactLoginFailureError } from "./login-gate.ts";
import { renderOfflineSidebarStatus, renderSessionRowBadges } from "./session-row-badges.ts";
const PALETTE_SHORTCUT = /Mac|iP(hone|ad|od)/i.test(globalThis.navigator?.platform ?? "")
? "⌘K"
@@ -209,6 +210,7 @@ class AppSidebar extends AppSidebarSessionListElement {
const mainKey = this.selectedAgentMainSessionKey(agentId);
const mainRow = this.mainSessionRow(agentId);
const approvalNeeded = sessionHasPendingApproval(this.approvalBadgeSnapshot(), mainKey);
const outboxCount = this.outboxCountForSessionKey(mainKey);
const active =
this.activeRouteId === "chat" &&
areUiSessionKeysEquivalent(this.getRouteSessionKey(), mainKey);
@@ -250,7 +252,7 @@ class AppSidebar extends AppSidebarSessionListElement {
>${icons.layoutDashboard}</span
>`
: nothing}
${stateBadge !== nothing || approvalNeeded
${stateBadge !== nothing || approvalNeeded || outboxCount > 0
? html`<span class="nav-item__state sidebar-home-session-states">
${stateBadge}
${approvalNeeded
@@ -262,6 +264,7 @@ class AppSidebar extends AppSidebarSessionListElement {
>${icons.alertTriangle}</span
>`
: nothing}
${renderSessionRowBadges({ hasAutomation: false, outboxCount })}
</span>`
: nothing}
</a>
@@ -343,17 +346,11 @@ class AppSidebar extends AppSidebarSessionListElement {
? html`<openclaw-tooltip
.content=${this.lastError ? redactLoginFailureError(this.lastError) : reconnecting}
>
<button
type="button"
class="sidebar-footer-bar__status"
aria-live="polite"
aria-label=${`${t("common.offline")}${t("connection.retryNow")}`}
@click=${() => this.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>
${renderOfflineSidebarStatus({
queuedOutboxCount: this.queuedOutboxCount,
reconnecting,
onRetry: () => this.onRetryConnect?.(),
})}
</openclaw-tooltip>`
: nothing}
<openclaw-tooltip .content=${t("nav.settings")}>
@@ -29,6 +29,25 @@ function renderBadges(placementState?: SessionPlacementState, workspaceConflictC
}
describe("session row placement badges", () => {
it("renders the durable outbox count and stays quiet when empty", () => {
render(
renderSessionRowBadges({
hasAutomation: false,
outboxCount: 3,
}),
container,
);
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");
expect(badge?.textContent).toContain("3");
expect(badge?.querySelector("svg")).not.toBeNull();
render(renderSessionRowBadges({ hasAutomation: false, outboxCount: 0 }), container);
expect(container.querySelector(".session-row-badges")).toBeNull();
});
it.each(["local", "reclaimed"] satisfies SessionPlacementState[])(
"keeps %s placement visually quiet",
(placementState) => {
+42
View File
@@ -42,6 +42,7 @@ export function renderSessionRowBadges(params: {
hasAutomation: boolean;
pullRequest?: SessionCatalogPullRequestSummary;
hasApproval?: boolean;
outboxCount?: number;
placementState?: SessionPlacementState;
workspaceConflictCount?: number;
}) {
@@ -59,10 +60,18 @@ export function renderSessionRowBadges(params: {
const conflictPlacementState = workspaceConflictCount > 0 ? params.placementState : undefined;
const displayedPlacementState = cloudPlacementState ?? conflictPlacementState;
const hasWorkspaceConflict = workspaceConflictCount > 0;
const outboxCount = Math.max(0, Math.floor(params.outboxCount ?? 0));
const outboxLabel =
outboxCount > 0
? t(outboxCount === 1 ? "sessionsView.queuedMessage" : "sessionsView.queuedMessages", {
count: String(outboxCount),
})
: "";
if (
!hasAutomation &&
!pullRequestLabel &&
!params.hasApproval &&
outboxCount === 0 &&
!displayedPlacementState &&
!hasWorkspaceConflict
) {
@@ -117,6 +126,15 @@ export function renderSessionRowBadges(params: {
>${icons.alertTriangle}</span
>`
: 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
>`
: nothing}
${displayedPlacementState || hasWorkspaceConflict
? html`<span
class="session-row-badge session-row-badge--cloud"
@@ -132,3 +150,27 @@ export function renderSessionRowBadges(params: {
: nothing}
</span>`;
}
export function renderOfflineSidebarStatus(props: {
queuedOutboxCount: number;
reconnecting: string;
title?: string;
onRetry: () => void;
}) {
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>`;
}
+6 -4
View File
@@ -336,12 +336,13 @@ describe("settings sidebar search", () => {
it("shows the offline retry action without an online status", () => {
const onRetryConnect = vi.fn();
const renderSidebar = (offline: boolean, lastError: string | null) =>
const renderSidebar = (offline: boolean, lastError: string | null, queuedOutboxCount = 0) =>
render(
renderSettingsSidebar({
basePath: "",
activeRouteId: "config",
offline,
queuedOutboxCount,
lastError,
version: "1.0.0",
updateAvailable: null,
@@ -357,13 +358,14 @@ describe("settings sidebar search", () => {
container,
);
renderSidebar(false, null);
renderSidebar(false, null, 3);
expect(container.querySelector(".sidebar-footer-bar__status")).toBeNull();
renderSidebar(true, "connection refused?token=settings-secret");
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?.getAttribute("aria-label")).toBe("Offline — Retry now");
expect(button?.textContent).toContain("3 queued");
expect(button?.getAttribute("aria-label")).toBe("Offline — Retry now — 3 queued");
button?.click();
expect(onRetryConnect).toHaveBeenCalledOnce();
});
+8 -12
View File
@@ -18,6 +18,7 @@ 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 { renderOfflineSidebarStatus } from "./session-row-badges.ts";
import "./sidebar-update-card.ts";
type SettingsSidebarProps = {
@@ -26,6 +27,7 @@ type SettingsSidebarProps = {
activeSearch?: string;
activeHash?: string;
offline: boolean;
queuedOutboxCount?: number;
lastError: string | null;
version: string;
updateAvailable: UpdateAvailable | null;
@@ -298,18 +300,12 @@ export function renderSettingsSidebar(props: SettingsSidebarProps) {
></openclaw-sidebar-update-card>
<footer class="settings-sidebar__footer">
${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>`
? renderOfflineSidebarStatus({
queuedOutboxCount: props.queuedOutboxCount ?? 0,
reconnecting,
title: props.lastError ? redactLoginFailureError(props.lastError) : reconnecting,
onRetry: props.onRetryConnect,
})
: nothing}
${props.version
? html`<span class="settings-sidebar__footer-version">${props.version}</span>`
+4
View File
@@ -646,6 +646,8 @@ export const en: TranslationMap = {
openWorkboardCard: "Open Workboard card",
dashboardAvailable: "Dashboard available",
approvalNeeded: "Approval needed",
queuedMessage: "{count} message queued to send",
queuedMessages: "{count} messages queued to send",
noSessions: "No threads found.",
noActiveSessions: "No active threads.",
noArchivedSessions: "No archived sessions.",
@@ -2974,6 +2976,7 @@ export const en: TranslationMap = {
eventStale: "Stale thread",
},
connection: {
queuedCount: "{count} queued",
reconnecting: "Reconnecting…",
retryNow: "Retry now",
access: {
@@ -4144,6 +4147,7 @@ export const en: TranslationMap = {
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.",
offlineQueuedHint: "Offline — {count} queued; messages send when the connection returns.",
preparingModel: "Preparing model...",
responding: "{name} is responding...",
sendingMessage: "Sending message...",
+200
View File
@@ -0,0 +1,200 @@
import { normalizeAgentId } from "../sessions/session-key.ts";
import type {
ChatAttachment,
ChatQueueItem,
ChatQueueSkillWorkshopRevision,
} from "./chat-types.ts";
import { normalizeSenderIdentity } from "./sender-label.ts";
export const MAX_STORED_SESSIONS = 20;
export const MAX_STORED_QUEUE_ITEMS = 50;
// Shipped v1 state could hold one full queue under each of 20 alias keys.
// Alias consolidation may exceed today's admission cap, but must retain every
// existing input while the canonical queue drains back below 50.
export const MAX_RETAINED_QUEUE_ITEMS = MAX_STORED_SESSIONS * MAX_STORED_QUEUE_ITEMS;
export const INTERRUPTED_SETTINGS_WAIT_ERROR =
"Chat settings update was interrupted. Review and retry when ready.";
export type StoredComposerSession = {
draft?: string;
draftRevision?: number;
queue?: ChatQueueItem[];
updatedAt: number;
};
export function normalizeOptionalString(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value : undefined;
}
function normalizeOptionalBoolean(value: unknown): boolean | undefined {
return typeof value === "boolean" ? value : undefined;
}
function normalizeChatAttachment(value: unknown): ChatAttachment | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
const entry = value as Record<string, unknown>;
const id = normalizeOptionalString(entry.id);
const mimeType = normalizeOptionalString(entry.mimeType);
if (!id || !mimeType) {
return null;
}
const restored: ChatAttachment = { id, mimeType };
const fileName = normalizeOptionalString(entry.fileName);
if (fileName) {
restored.fileName = fileName;
}
if (typeof entry.sizeBytes === "number" && Number.isFinite(entry.sizeBytes)) {
restored.sizeBytes = entry.sizeBytes;
}
const dataUrl = normalizeOptionalString(entry.dataUrl);
if (dataUrl) {
restored.dataUrl = dataUrl;
}
return restored;
}
export function normalizeSkillWorkshopRevision(
value: unknown,
): ChatQueueSkillWorkshopRevision | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return undefined;
}
const entry = value as Record<string, unknown>;
const proposalId = normalizeOptionalString(entry.proposalId);
if (!proposalId) {
return undefined;
}
const agentId = normalizeOptionalString(entry.agentId);
return {
proposalId,
...(agentId ? { agentId: normalizeAgentId(agentId) } : {}),
};
}
function normalizeQueueItem(value: unknown): ChatQueueItem | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
const entry = value as Record<string, unknown>;
const id = normalizeOptionalString(entry.id);
const text = typeof entry.text === "string" ? entry.text : "";
const createdAt =
typeof entry.createdAt === "number" && Number.isFinite(entry.createdAt)
? entry.createdAt
: Date.now();
if (!id || (!text.trim() && !Array.isArray(entry.attachments))) {
return null;
}
const attachments = Array.isArray(entry.attachments)
? entry.attachments
.map(normalizeChatAttachment)
.filter((item): item is ChatAttachment => item !== null)
: [];
const item: ChatQueueItem = { id, text, createdAt };
const sender = normalizeSenderIdentity(entry.sender as Record<string, unknown> | undefined);
if (sender) {
item.sender = sender;
}
if (entry.kind === "queued" || entry.kind === "steered") {
item.kind = entry.kind;
}
if (attachments.length) {
item.attachments = attachments;
}
const refreshSessions = normalizeOptionalBoolean(entry.refreshSessions);
if (refreshSessions !== undefined) {
item.refreshSessions = refreshSessions;
}
const replyToId = normalizeOptionalString(entry.replyToId);
if (replyToId) {
item.replyToId = replyToId;
}
if (
entry.sendState === "failed" ||
entry.sendState === "unconfirmed" ||
entry.sendState === "waiting-idle" ||
entry.sendState === "waiting-reconnect"
) {
item.sendState = entry.sendState;
} else if (entry.sendState === "waiting-model") {
item.sendState = "failed";
item.sendError = INTERRUPTED_SETTINGS_WAIT_ERROR;
}
const sendError = normalizeOptionalString(entry.sendError);
if (sendError) {
item.sendError = sendError;
}
const sendRunId = normalizeOptionalString(entry.sendRunId);
if (sendRunId) {
item.sendRunId = sendRunId;
}
if (typeof entry.sendAttempts === "number" && Number.isFinite(entry.sendAttempts)) {
item.sendAttempts = entry.sendAttempts;
}
const localCommandArgs = normalizeOptionalString(entry.localCommandArgs);
if (localCommandArgs) {
item.localCommandArgs = localCommandArgs;
}
const localCommandName = normalizeOptionalString(entry.localCommandName);
if (localCommandName) {
item.localCommandName = localCommandName;
}
const sessionKey = normalizeOptionalString(entry.sessionKey);
if (sessionKey) {
item.sessionKey = sessionKey;
}
const agentId = normalizeOptionalString(entry.agentId);
if (agentId) {
item.agentId = normalizeAgentId(agentId);
}
const skillWorkshopRevision = normalizeSkillWorkshopRevision(entry.skillWorkshopRevision);
if (skillWorkshopRevision) {
item.skillWorkshopRevision = skillWorkshopRevision;
}
return item;
}
export function normalizeStoredSession(value: unknown): StoredComposerSession | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
const entry = value as Record<string, unknown>;
const draft = typeof entry.draft === "string" ? entry.draft : undefined;
const normalizedQueue = Array.isArray(entry.queue)
? entry.queue
.slice(0, MAX_RETAINED_QUEUE_ITEMS)
.map(normalizeQueueItem)
.filter((item): item is ChatQueueItem => item !== null)
: undefined;
// v1 writers used bounded tombstones. Consume them while reading legacy
// state, but never copy them into the item-level outbox representation.
const removedQueueItemIds = Array.isArray(entry.removedQueueItemIds)
? entry.removedQueueItemIds
.map(normalizeOptionalString)
.filter((id): id is string => id !== undefined)
: undefined;
const removedIds = new Set(removedQueueItemIds ?? []);
const queue = normalizedQueue?.filter((item) => !removedIds.has(item.id));
const updatedAt =
typeof entry.updatedAt === "number" && Number.isFinite(entry.updatedAt)
? entry.updatedAt
: Date.now();
const storedDraftRevision =
typeof entry.draftRevision === "number" && Number.isSafeInteger(entry.draftRevision)
? entry.draftRevision
: undefined;
// Legacy rows did not version drafts, so their row timestamp is the best
// available ordering signal. Queue-only rows must not claim draft ownership.
const draftRevision = storedDraftRevision ?? (draft ? updatedAt : undefined);
if (!draft && draftRevision === undefined && (!queue || queue.length === 0)) {
return null;
}
return {
...(draft ? { draft } : {}),
...(draftRevision !== undefined ? { draftRevision } : {}),
...(queue && queue.length > 0 ? { queue } : {}),
updatedAt,
};
}
@@ -0,0 +1,70 @@
let lastIssuedDraftRevision = 0;
const draftRevisionHighWaterByStorage = new WeakMap<Storage, Map<string, Map<string, number>>>();
const draftAttemptHighWaterByStorage = new WeakMap<Storage, Map<string, Map<string, number>>>();
export function observeDraftRevision(draftRevision: number | undefined): void {
lastIssuedDraftRevision = Math.max(lastIssuedDraftRevision, draftRevision ?? 0);
}
export function nextDraftRevision(baseline = 0): number {
const revision = Math.max(Date.now(), lastIssuedDraftRevision + 1, baseline + 1);
lastIssuedDraftRevision = revision;
return revision;
}
export function rememberDraftRevision(
storage: Storage,
storageKey: string,
storeSessionKey: string,
draftRevision: number | undefined,
) {
if (draftRevision === undefined) {
return;
}
let byStorageKey = draftRevisionHighWaterByStorage.get(storage);
if (!byStorageKey) {
byStorageKey = new Map();
draftRevisionHighWaterByStorage.set(storage, byStorageKey);
}
let bySession = byStorageKey.get(storageKey);
if (!bySession) {
bySession = new Map();
byStorageKey.set(storageKey, bySession);
}
bySession.set(storeSessionKey, Math.max(bySession.get(storeSessionKey) ?? 0, draftRevision));
}
export function rememberDraftAttempt(
storage: Storage,
storageKey: string,
storeSessionKey: string,
draftRevision: number,
) {
let byStorageKey = draftAttemptHighWaterByStorage.get(storage);
if (!byStorageKey) {
byStorageKey = new Map();
draftAttemptHighWaterByStorage.set(storage, byStorageKey);
}
let bySession = byStorageKey.get(storageKey);
if (!bySession) {
bySession = new Map();
byStorageKey.set(storageKey, bySession);
}
bySession.set(storeSessionKey, Math.max(bySession.get(storeSessionKey) ?? 0, draftRevision));
}
export function rememberedDraftRevision(
storage: Storage,
storageKey: string,
storeSessionKey: string,
): number {
return draftRevisionHighWaterByStorage.get(storage)?.get(storageKey)?.get(storeSessionKey) ?? 0;
}
export function rememberedDraftAttempt(
storage: Storage,
storageKey: string,
storeSessionKey: string,
): number {
return draftAttemptHighWaterByStorage.get(storage)?.get(storageKey)?.get(storeSessionKey) ?? 0;
}
+197
View File
@@ -0,0 +1,197 @@
// @vitest-environment node
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createStorageMock } from "../../test-helpers/storage.ts";
import {
resolveStoredChatOutboxScope,
storedChatOutboxScopeKey,
summarizeStoredChatOutboxes,
} from "./outbox-store.ts";
beforeEach(() => {
vi.stubGlobal("sessionStorage", createStorageMock());
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe("stored outbox summaries", () => {
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)}`;
sessionStorage.setItem(
legacyKey,
JSON.stringify({
version: 1,
sessions: {
"main\u0000agent:previous": {
queue: [{ id: "queued", text: "queued", createdAt: 1 }],
updatedAt: 1,
},
},
}),
);
const summary = summarizeStoredChatOutboxes({
settings: { gatewayUrl },
assistantAgentId: "previous",
agentsList: { defaultId: "work", mainKey: "main" },
});
expect(summary.total).toBe(1);
expect(
summary.countsByScope.get(
storedChatOutboxScopeKey({ sessionKey: "global", agentId: "work" }),
),
).toBe(1);
expect(
summary.countsByScope.get(
storedChatOutboxScopeKey({ sessionKey: "global", agentId: "previous" }),
),
).toBeUndefined();
});
it("refreshes custom-main ownership for a later offline reload", () => {
const gatewayUrl = "ws://gateway.test/control";
const storageKey = `openclaw.control.chatComposer.v2:${encodeURIComponent(gatewayUrl)}`;
sessionStorage.setItem(
storageKey,
JSON.stringify({
version: 2,
gatewayOwner: gatewayUrl,
mainAlias: { key: "old-main", agentId: "previous" },
sessions: {},
}),
);
summarizeStoredChatOutboxes({
settings: { gatewayUrl },
agentsList: { defaultId: "work", mainKey: "workspace" },
});
expect(JSON.parse(sessionStorage.getItem(storageKey) ?? "{}").mainAlias).toEqual({
key: "workspace",
agentId: "work",
});
expect(
resolveStoredChatOutboxScope(
{ settings: { gatewayUrl }, agentsList: null, hello: null },
"workspace",
),
).toEqual({ sessionKey: "global", agentId: "work" });
});
it("resolves legacy bare-main rows through the persisted alias on an offline reload", () => {
const gatewayUrl = "ws://gateway.test/control";
const storageKey = `openclaw.control.chatComposer.v2:${encodeURIComponent(gatewayUrl)}`;
sessionStorage.setItem(
storageKey,
JSON.stringify({
version: 2,
gatewayOwner: gatewayUrl,
mainAlias: { key: "main", agentId: "work" },
sessions: {
"main\u0000agent:previous": {
queue: [{ id: "queued", text: "queued", createdAt: 1 }],
updatedAt: 1,
},
},
}),
);
// Offline reload: no session defaults available, only the persisted alias.
const offlineState = { settings: { gatewayUrl }, agentsList: null, hello: null };
const summary = summarizeStoredChatOutboxes(offlineState);
expect(summary.total).toBe(1);
const sidebarScopeKey = storedChatOutboxScopeKey(
resolveStoredChatOutboxScope(offlineState, "main"),
);
expect(summary.countsByScope.get(sidebarScopeKey)).toBe(1);
});
it("rejects a v2 store owned by another gateway", () => {
const gatewayUrl = "ws://gateway.test/control";
const storageKey = `openclaw.control.chatComposer.v2:${encodeURIComponent(gatewayUrl)}`;
sessionStorage.setItem(
storageKey,
JSON.stringify({
version: 2,
gatewayOwner: "ws://other.test/control",
sessions: {
"global\u0000agent:work": {
queue: [{ id: "queued", text: "queued", createdAt: 1 }],
updatedAt: 1,
},
},
}),
);
expect(
summarizeStoredChatOutboxes({
settings: { gatewayUrl },
agentsList: { defaultId: "work", mainKey: "workspace" },
}).total,
).toBe(0);
expect(JSON.parse(sessionStorage.getItem(storageKey) ?? "{}").gatewayOwner).toBe(
"ws://other.test/control",
);
});
it("retains custom-main aliases independently for each gateway", () => {
for (const [gatewayUrl, key, agentId] of [
["ws://a.test/control", "workspace-a", "alpha"],
["ws://b.test/control", "workspace-b", "beta"],
] as const) {
sessionStorage.setItem(
`openclaw.control.chatComposer.v2:${encodeURIComponent(gatewayUrl)}`,
JSON.stringify({
version: 2,
gatewayOwner: gatewayUrl,
mainAlias: { key, agentId },
sessions: {},
}),
);
summarizeStoredChatOutboxes({ settings: { gatewayUrl }, agentsList: null, hello: null });
}
expect(
resolveStoredChatOutboxScope(
{ settings: { gatewayUrl: "ws://a.test/control" }, agentsList: null, hello: null },
"workspace-a",
),
).toEqual({ sessionKey: "global", agentId: "alpha" });
expect(
resolveStoredChatOutboxScope(
{ settings: { gatewayUrl: "ws://b.test/control" }, agentsList: null, hello: null },
"workspace-b",
),
).toEqual({ sessionKey: "global", agentId: "beta" });
});
it("deduplicates item ids within a scope, not across scopes", () => {
const gatewayUrl = "ws://gateway.test/control";
sessionStorage.setItem(
`openclaw.control.chatComposer.v2:${encodeURIComponent(gatewayUrl)}`,
JSON.stringify({
version: 2,
gatewayOwner: gatewayUrl,
sessions: {
"thread-a\u0000agent:main": {
queue: [{ id: "same", text: "first", createdAt: 1 }],
updatedAt: 1,
},
"thread-b\u0000agent:main": {
queue: [{ id: "same", text: "second", createdAt: 2 }],
updatedAt: 2,
},
},
}),
);
const summary = summarizeStoredChatOutboxes({ settings: { gatewayUrl } });
expect(summary.total).toBe(2);
expect(summary.countsByScope.get(storedChatOutboxScopeKey({ sessionKey: "thread-a" }))).toBe(1);
expect(summary.countsByScope.get(storedChatOutboxScopeKey({ sessionKey: "thread-b" }))).toBe(1);
});
});
+337
View File
@@ -0,0 +1,337 @@
import { getSafeSessionStorage } from "../../local-storage.ts";
import {
DEFAULT_AGENT_ID,
DEFAULT_MAIN_KEY,
normalizeAgentId,
parseAgentSessionKey,
resolveUiConfiguredMainKey,
resolveUiDefaultAgentId,
resolveUiKnownSelectedGlobalAgentId,
} from "../sessions/session-key.ts";
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>();
export type ChatComposerScope = {
settings?: { gatewayUrl?: string | null };
assistantAgentId?: string | null;
agentsList?: { defaultId?: string | null; mainKey?: string | null } | null;
hello?: { snapshot?: unknown } | null;
};
export type StoredComposerMainAlias = {
key: string;
agentId: string;
};
export type ComposerStorageTarget = {
key: string;
legacyKey: string;
gatewayOwner: string;
legacyOwnerIsUnambiguous: boolean;
};
export type ComposerStorageScope = {
conversationKey: string;
agentScope: string;
routingAgentId?: string;
isGlobal: boolean;
};
export type StoredChatOutboxScope = {
sessionKey: string;
agentId?: string;
};
type StoredChatOutboxSummary = {
countsByScope: ReadonlyMap<string, number>;
total: number;
};
const storedMainAliasByStorage = new WeakMap<
Storage,
Map<string, StoredComposerMainAlias | null>
>();
export function subscribeStoredChatOutboxChanges(listener: () => void): () => void {
storedChatOutboxChangeListeners.add(listener);
return () => storedChatOutboxChangeListeners.delete(listener);
}
export function notifyStoredChatOutboxChanges(): void {
for (const listener of storedChatOutboxChangeListeners) {
try {
listener();
} catch (error) {
console.error("[openclaw] stored chat outbox listener failed", error);
}
}
}
export function storageTargetForGateway(
gatewayUrl: string | null | undefined,
): ComposerStorageTarget {
const gatewayOwner = gatewayUrl?.trim() || "default";
const encodedOwner = encodeURIComponent(gatewayOwner);
return {
key: `${STORAGE_KEY_PREFIX}${encodedOwner}`,
legacyKey: `${LEGACY_STORAGE_KEY_PREFIX}${encodedOwner.slice(0, 240)}`,
gatewayOwner,
// Shipped v1 keys omitted the owner and truncated its encoded value. A
// truncated row cannot prove which same-prefix gateway owns its outbox.
legacyOwnerIsUnambiguous: encodedOwner.length < 240,
};
}
function hasKnownSessionDefaults(state: ChatComposerScope): boolean {
if (state.agentsList != null) {
return true;
}
const snapshot = state.hello?.snapshot;
return Boolean(
snapshot &&
typeof snapshot === "object" &&
"sessionDefaults" in snapshot &&
snapshot.sessionDefaults &&
typeof snapshot.sessionDefaults === "object",
);
}
export function rememberStoredMainAlias(
storage: Storage,
storageKey: string,
mainAlias: StoredComposerMainAlias | undefined,
) {
let byStorageKey = storedMainAliasByStorage.get(storage);
if (!byStorageKey) {
byStorageKey = new Map();
storedMainAliasByStorage.set(storage, byStorageKey);
}
byStorageKey.set(storageKey, mainAlias ?? null);
}
function rememberedStoredMainAlias(
storage: Storage,
storageKey: string,
): StoredComposerMainAlias | undefined {
return storedMainAliasByStorage.get(storage)?.get(storageKey) ?? undefined;
}
export function resolveComposerStorageScope(
state: ChatComposerScope,
sessionKey: string,
agentIdOverride?: string,
storedMainAlias?: StoredComposerMainAlias,
): ComposerStorageScope {
const parsed = parseAgentSessionKey(sessionKey);
const normalizedSessionKey = sessionKey.trim().toLowerCase();
const knownSessionDefaults = hasKnownSessionDefaults(state);
const configuredMainKey = resolveUiConfiguredMainKey(state);
const bareGlobalAlias =
normalizedSessionKey === DEFAULT_MAIN_KEY || normalizedSessionKey === configuredMainKey;
const storedAliasCandidate = parsed?.rest ?? normalizedSessionKey;
const storedMainAliasMatches =
!knownSessionDefaults && storedMainAlias?.key === storedAliasCandidate;
const storedBareMainAliasAgentId =
!knownSessionDefaults &&
!parsed &&
storedMainAlias &&
(normalizedSessionKey === DEFAULT_MAIN_KEY || storedMainAliasMatches)
? storedMainAlias.agentId
: undefined;
const unresolvedBareMain =
!knownSessionDefaults && !parsed && normalizedSessionKey === DEFAULT_MAIN_KEY;
const parsedGlobalAlias =
parsed &&
(parsed.rest === "global" ||
parsed.rest === DEFAULT_MAIN_KEY ||
parsed.rest === configuredMainKey);
const isGlobal =
normalizedSessionKey === "global" ||
bareGlobalAlias ||
Boolean(parsedGlobalAlias) ||
storedMainAliasMatches;
const explicitAgentId = parsed?.agentId ?? agentIdOverride?.trim();
const knownAgentId = resolveUiKnownSelectedGlobalAgentId(state);
const bareGlobalAgentId =
knownSessionDefaults && !parsed && bareGlobalAlias ? resolveUiDefaultAgentId(state) : undefined;
const routingAgentId = isGlobal
? explicitAgentId
? normalizeAgentId(explicitAgentId)
: bareGlobalAgentId
? bareGlobalAgentId
: storedBareMainAliasAgentId
? storedBareMainAliasAgentId
: unresolvedBareMain
? undefined
: knownAgentId
? knownAgentId
: storedMainAliasMatches
? storedMainAlias.agentId
: undefined
: parsed?.agentId
? normalizeAgentId(parsed.agentId)
: undefined;
const agentScope =
routingAgentId ?? (isGlobal ? UNRESOLVED_GLOBAL_AGENT_SCOPE : DEFAULT_AGENT_ID);
// Before Gateway defaults load, bare `main` means the unknown default agent
// while raw `global` means the unknown selected agent. Keep their durable
// rows distinct until those two owners can be resolved.
const preserveBareMainRoute = unresolvedBareMain && !routingAgentId;
return {
conversationKey: preserveBareMainRoute ? DEFAULT_MAIN_KEY : isGlobal ? "global" : sessionKey,
agentScope,
...(routingAgentId ? { routingAgentId } : {}),
isGlobal,
};
}
function storageSessionKeyForAgentScope(sessionKey: string, agentScope: string): string {
return `${sessionKey}\u0000agent:${agentScope}`;
}
export function resolveStoredChatOutboxScope(
state: ChatComposerScope,
sessionKey: string,
agentIdOverride?: string,
): StoredChatOutboxScope {
const storage = getSafeSessionStorage();
const target = storageTargetForGateway(state.settings?.gatewayUrl);
const storedMainAlias = storage ? rememberedStoredMainAlias(storage, target.key) : undefined;
const scope = resolveComposerStorageScope(state, sessionKey, agentIdOverride, storedMainAlias);
return {
sessionKey: scope.conversationKey,
...(scope.routingAgentId ? { agentId: scope.routingAgentId } : {}),
};
}
export function storedChatOutboxScopeKey(scope: StoredChatOutboxScope): string {
const normalizedSessionKey = scope.sessionKey.trim().toLowerCase();
const agentScope =
scope.agentId ??
(normalizedSessionKey === "global" || normalizedSessionKey === DEFAULT_MAIN_KEY
? UNRESOLVED_GLOBAL_AGENT_SCOPE
: DEFAULT_AGENT_ID);
return storageSessionKeyForAgentScope(scope.sessionKey, agentScope);
}
type StoredChatOutboxSummaryState = {
version?: 1 | 2;
gatewayOwner?: string;
mainAlias?: StoredComposerMainAlias;
sessions?: Record<
string,
{
queue?: Array<{ id?: string; pendingRunId?: string }>;
removedQueueItemIds?: string[];
}
>;
};
const EMPTY_STORED_CHAT_OUTBOX_SUMMARY: StoredChatOutboxSummary = {
countsByScope: new Map(),
total: 0,
};
export function summarizeStoredChatOutboxes(state: ChatComposerScope): StoredChatOutboxSummary {
const storage = getSafeSessionStorage();
if (!storage) {
return EMPTY_STORED_CHAT_OUTBOX_SUMMARY;
}
try {
const target = storageTargetForGateway(state.settings?.gatewayUrl);
const currentRaw = storage.getItem(target.key);
const raw =
currentRaw ?? (target.legacyOwnerIsUnambiguous ? storage.getItem(target.legacyKey) : null);
if (!raw) {
rememberStoredMainAlias(storage, target.key, undefined);
return EMPTY_STORED_CHAT_OUTBOX_SUMMARY;
}
const parsed = JSON.parse(raw) as StoredChatOutboxSummaryState;
if (
!parsed.sessions ||
(parsed.version !== 1 &&
(parsed.version !== 2 || parsed.gatewayOwner !== target.gatewayOwner))
) {
return EMPTY_STORED_CHAT_OUTBOX_SUMMARY;
}
let mainAlias = parsed.mainAlias;
if (hasKnownSessionDefaults(state)) {
const mainKey = resolveUiConfiguredMainKey(state);
const refreshed =
mainKey === DEFAULT_MAIN_KEY
? undefined
: { key: mainKey, agentId: resolveUiDefaultAgentId(state) };
if (mainAlias?.key !== refreshed?.key || mainAlias?.agentId !== refreshed?.agentId) {
mainAlias = refreshed;
try {
const { mainAlias: _stale, ...stored } = parsed;
storage.setItem(
target.key,
JSON.stringify({
...stored,
version: 2,
gatewayOwner: target.gatewayOwner,
...(mainAlias ? { mainAlias } : {}),
}),
);
if (!currentRaw) {
storage.removeItem(target.legacyKey);
}
} catch {
// Readable queued state remains usable when alias refresh cannot persist.
}
}
}
rememberStoredMainAlias(storage, target.key, mainAlias);
const itemIdsByScope = new Map<string, Set<string>>();
const separator = "\u0000agent:";
for (const [storeSessionKey, session] of Object.entries(parsed.sessions)) {
const separatorIndex = storeSessionKey.lastIndexOf(separator);
if (separatorIndex < 0 || !session.queue?.length) {
continue;
}
const agentScope = storeSessionKey.slice(separatorIndex + separator.length);
const rawSessionKey = storeSessionKey.slice(0, separatorIndex);
const normalizedRawSessionKey = rawSessionKey.trim().toLowerCase();
// A main-key row's embedded agent suffix is not authoritative: legacy rows
// keep the writer's agent id. Resolve through session defaults (online) or
// the persisted mainAlias (offline reload) so counts match sidebar scopes.
const resolveToDefaultAgent = hasKnownSessionDefaults(state)
? normalizedRawSessionKey === DEFAULT_MAIN_KEY ||
normalizedRawSessionKey === resolveUiConfiguredMainKey(state)
: normalizedRawSessionKey === DEFAULT_MAIN_KEY ||
(mainAlias !== undefined &&
normalizedRawSessionKey === mainAlias.key.trim().toLowerCase());
const scope = resolveStoredChatOutboxScope(
state,
rawSessionKey,
resolveToDefaultAgent || agentScope === UNRESOLVED_GLOBAL_AGENT_SCOPE
? undefined
: agentScope,
);
const scopeKey = storedChatOutboxScopeKey(scope);
const itemIds = itemIdsByScope.get(scopeKey) ?? new Set<string>();
for (const item of session.queue) {
const id = item.id;
if (id && !item.pendingRunId && !session.removedQueueItemIds?.includes(id)) {
itemIds.add(id);
}
}
if (itemIds.size) {
itemIdsByScope.set(scopeKey, itemIds);
}
}
const countsByScope = new Map<string, number>();
let total = 0;
for (const [scopeKey, itemIds] of itemIdsByScope) {
countsByScope.set(scopeKey, itemIds.size);
total += itemIds.size;
}
return { countsByScope, total };
} catch {
return EMPTY_STORED_CHAT_OUTBOX_SUMMARY;
}
}
+12 -3
View File
@@ -144,16 +144,25 @@ afterEach(async () => {
describe("renderChatComposer controls", () => {
it("keeps composing enabled and explains queued delivery while offline", () => {
const { container } = renderComposer({ offline: true, draft: "Queue this message" });
const { container } = renderComposer({
offline: true,
queuedOutboxCount: 3,
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.",
"Offline — 3 queued; messages send 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();
const empty = renderComposer({ offline: true, queuedOutboxCount: 0 });
expect(empty.container.querySelector(".agent-chat__offline-hint")?.textContent?.trim()).toBe(
"Offline — messages will be queued and sent when the connection returns.",
);
const online = renderComposer({ queuedOutboxCount: 3 });
expect(online.container.querySelector(".agent-chat__offline-hint")).toBeNull();
});
+1
View File
@@ -3436,6 +3436,7 @@ class ChatPane extends OpenClawLightDomElement {
followUpMode: state.chatFollowUpMode,
draft: state.chatMessage,
queue: state.chatQueue,
queuedOutboxCount: state.chatQueue.filter((item) => !item.pendingRunId).length,
realtimeTalkActive: state.realtimeTalkActive,
realtimeTalkStatus: state.realtimeTalkStatus,
realtimeTalkDetail: state.realtimeTalkDetail,
+2
View File
@@ -125,6 +125,7 @@ export type ChatProps = {
assistantAvatarUrl?: string | null;
draft: string;
queue: ChatQueueItem[];
queuedOutboxCount?: number;
realtimeTalkActive?: boolean;
realtimeTalkStatus?: RealtimeTalkStatus;
realtimeTalkDetail?: string | null;
@@ -401,6 +402,7 @@ export function renderChat(props: ChatProps) {
currentAgentId: props.currentAgentId,
connected: props.connected,
offline: props.offline,
queuedOutboxCount: props.queuedOutboxCount,
canSend: props.canSend,
disabledReason: props.disabledReason,
disabledBanner: props.disabledBanner,
@@ -101,6 +101,7 @@ type ChatComposerProps = {
currentAgentId: string;
connected: boolean;
offline?: boolean;
queuedOutboxCount?: number;
canSend: boolean;
disabledReason: string | null;
disabledBanner?: { text: string; actionLabel: string; onAction: () => void };
@@ -2750,7 +2751,11 @@ export function renderChatComposer(props: ChatComposerProps) {
>
${props.offline
? html`<div class="agent-chat__offline-hint" role="status" aria-live="polite">
${t("chat.composer.offlineHint")}
${props.queuedOutboxCount
? t("chat.composer.offlineQueuedHint", {
count: String(props.queuedOutboxCount),
})
: t("chat.composer.offlineHint")}
</div>`
: nothing}
${slashMenuVisible ? renderSlashMenu(requestUpdate, props, visibleDraft) : nothing}
+492
View File
@@ -0,0 +1,492 @@
import type { ChatQueueItem } from "../../lib/chat/chat-types.ts";
import {
MAX_RETAINED_QUEUE_ITEMS,
MAX_STORED_SESSIONS,
normalizeStoredSession,
type StoredComposerSession,
} from "../../lib/chat/outbox-store-codec.ts";
import {
rememberStoredMainAlias,
resolveComposerStorageScope,
storageTargetForGateway,
UNRESOLVED_GLOBAL_AGENT_SCOPE,
type ChatComposerScope,
type ComposerStorageScope,
type ComposerStorageTarget,
type StoredChatOutboxScope,
type StoredComposerMainAlias,
} from "../../lib/chat/outbox-store.ts";
import {
DEFAULT_MAIN_KEY,
normalizeAgentId,
parseAgentSessionKey,
resolveUiConfiguredMainKey,
resolveUiDefaultAgentId,
resolveUiKnownSelectedGlobalAgentId,
} from "../../lib/sessions/session-key.ts";
import { getSafeSessionStorage } from "../../local-storage.ts";
export {
INTERRUPTED_SETTINGS_WAIT_ERROR,
MAX_STORED_QUEUE_ITEMS,
normalizeOptionalString,
normalizeSkillWorkshopRevision,
normalizeStoredSession,
} from "../../lib/chat/outbox-store-codec.ts";
export type { StoredComposerSession } from "../../lib/chat/outbox-store-codec.ts";
export type StoredComposerState = {
version: 2;
gatewayOwner: string;
sessions: Record<string, StoredComposerSession>;
mainAlias?: StoredComposerMainAlias;
};
export type StoredChatOutbox = StoredChatOutboxScope & {
queue: ChatQueueItem[];
};
function hasKnownSessionDefaults(state: ChatComposerScope): boolean {
if (state.agentsList !== null && state.agentsList !== undefined) {
return true;
}
const snapshot = state.hello?.snapshot;
if (!snapshot || typeof snapshot !== "object" || !("sessionDefaults" in snapshot)) {
return false;
}
return Boolean(snapshot.sessionDefaults && typeof snapshot.sessionDefaults === "object");
}
function updateStoredMainAlias(store: StoredComposerState, state: ChatComposerScope): boolean {
if (!hasKnownSessionDefaults(state)) {
return false;
}
const key = resolveUiConfiguredMainKey(state);
if (key === DEFAULT_MAIN_KEY) {
if (!store.mainAlias) {
return false;
}
delete store.mainAlias;
return true;
}
const next = { key, agentId: resolveUiDefaultAgentId(state) };
if (store.mainAlias?.key === next.key && store.mainAlias.agentId === next.agentId) {
return false;
}
store.mainAlias = next;
return true;
}
function storageSessionKeyForAgentScope(sessionKey: string, agentScope: string): string {
return `${sessionKey}\u0000agent:${agentScope}`;
}
function mergeStoredComposerSessions(
current: StoredComposerSession | null,
incoming: StoredComposerSession,
): StoredComposerSession {
if (!current) {
return incoming;
}
// Incoming rows are visited in storage insertion order, so they win a
// millisecond timestamp tie instead of letting an older canonical row mask a
// just-written alias or unresolved draft.
const newest = current.updatedAt > incoming.updatedAt ? current : incoming;
const older = newest === current ? incoming : current;
const currentDraftRevision = current.draftRevision;
const incomingDraftRevision = incoming.draftRevision;
const newestDraftOwner =
currentDraftRevision === undefined
? incomingDraftRevision === undefined
? null
: incoming
: incomingDraftRevision === undefined
? current
: currentDraftRevision > incomingDraftRevision
? current
: incoming;
const queueById = new Map(
[...(older.queue ?? []), ...(newest.queue ?? [])].map((item) => [item.id, item]),
);
const queue = Array.from(queueById.values())
.toSorted((left, right) => left.createdAt - right.createdAt)
.slice(0, MAX_RETAINED_QUEUE_ITEMS);
return {
...(newestDraftOwner?.draft ? { draft: newestDraftOwner.draft } : {}),
...(newestDraftOwner?.draftRevision !== undefined
? { draftRevision: newestDraftOwner.draftRevision }
: {}),
...(queue.length ? { queue } : {}),
updatedAt: Math.max(current.updatedAt, incoming.updatedAt),
};
}
export function resolveStoredComposerSession(
store: StoredComposerState,
state: ChatComposerScope,
sessionKey: string,
agentIdOverride?: string,
): { session: StoredComposerSession | null; storeSessionKey: string; migrated: boolean } {
let migrated = updateStoredMainAlias(store, state);
const scope = resolveComposerStorageScope(state, sessionKey, agentIdOverride, store.mainAlias);
const storeSessionKey = storageSessionKeyForAgentScope(scope.conversationKey, scope.agentScope);
const configuredMainKey = resolveUiConfiguredMainKey(state);
const defaultGlobalAgentId = hasKnownSessionDefaults(state)
? resolveUiDefaultAgentId(state)
: undefined;
if (defaultGlobalAgentId) {
const defaultGlobalKey = storageSessionKeyForAgentScope("global", defaultGlobalAgentId);
let defaultGlobalSession = normalizeStoredSession(store.sessions[defaultGlobalKey]);
const bareMainAliases = new Set([DEFAULT_MAIN_KEY, configuredMainKey]);
const agentSeparator = "\u0000agent:";
for (const legacySessionKey of Object.keys(store.sessions)) {
if (legacySessionKey === defaultGlobalKey) {
continue;
}
const separatorIndex = legacySessionKey.lastIndexOf(agentSeparator);
if (separatorIndex < 0) {
continue;
}
const legacyRawSessionKey = legacySessionKey.slice(0, separatorIndex).trim().toLowerCase();
if (!bareMainAliases.has(legacyRawSessionKey)) {
continue;
}
const legacySession = normalizeStoredSession(store.sessions[legacySessionKey]);
if (!legacySession) {
continue;
}
// Shipped v1 scoped every unparsed bare route to the selected agent.
// Bare main aliases are default-agent routes; qualified agent routes
// keep their explicit owner because their raw key cannot match here.
const migratedQueue = legacySession.queue?.map((item) => ({
...item,
agentId: defaultGlobalAgentId,
sessionKey: "global",
}));
defaultGlobalSession = mergeStoredComposerSessions(defaultGlobalSession, {
...legacySession,
...(migratedQueue ? { queue: migratedQueue } : {}),
});
store.sessions[defaultGlobalKey] = defaultGlobalSession;
delete store.sessions[legacySessionKey];
migrated = true;
}
}
let session = normalizeStoredSession(store.sessions[storeSessionKey]);
if (!scope.isGlobal && !parseAgentSessionKey(sessionKey)) {
const legacyPrefix = `${scope.conversationKey}\u0000agent:`;
for (const legacySessionKey of Object.keys(store.sessions)) {
if (legacySessionKey === storeSessionKey || !legacySessionKey.startsWith(legacyPrefix)) {
continue;
}
const legacySession = normalizeStoredSession(store.sessions[legacySessionKey]);
if (!legacySession) {
continue;
}
// Shipped v1 assigned every unparsed route to the selected agent. Merge
// exact raw-route rows into the agentless key before mutation, or queued
// input can be listed but never updated or removed.
const migratedQueue = legacySession.queue?.map(({ agentId: _agentId, ...item }) => ({
...item,
sessionKey: scope.conversationKey,
}));
session = mergeStoredComposerSessions(session, {
...legacySession,
...(migratedQueue ? { queue: migratedQueue } : {}),
});
store.sessions[storeSessionKey] = session;
delete store.sessions[legacySessionKey];
migrated = true;
}
}
const agentSuffix = `\u0000agent:${scope.agentScope}`;
for (const legacySessionKey of Object.keys(store.sessions)) {
if (legacySessionKey === storeSessionKey || !legacySessionKey.endsWith(agentSuffix)) {
continue;
}
const legacyRawSessionKey = legacySessionKey.slice(0, -agentSuffix.length);
const legacyScope = resolveComposerStorageScope(
state,
legacyRawSessionKey,
scope.agentScope === UNRESOLVED_GLOBAL_AGENT_SCOPE ? undefined : scope.agentScope,
store.mainAlias,
);
if (legacyScope.conversationKey !== scope.conversationKey) {
continue;
}
const legacySession = normalizeStoredSession(store.sessions[legacySessionKey]);
if (legacySession) {
// Shipped qualified-main rows retain their alias in each queue item.
// Canonicalize those embedded routes with the row, or replay mutations
// cannot match the restored global item against durable storage.
const migratedQueue = legacySession.queue?.map(({ agentId: _agentId, ...item }) => ({
...item,
sessionKey: scope.conversationKey,
...(scope.routingAgentId ? { agentId: scope.routingAgentId } : {}),
}));
session = mergeStoredComposerSessions(session, {
...legacySession,
...(migratedQueue ? { queue: migratedQueue } : {}),
});
store.sessions[storeSessionKey] = session;
delete store.sessions[legacySessionKey];
migrated = true;
}
}
if (!scope.isGlobal) {
return { session, storeSessionKey, migrated };
}
const selectedGlobalAgentId = resolveUiKnownSelectedGlobalAgentId(state);
if (!selectedGlobalAgentId || scope.agentScope !== selectedGlobalAgentId) {
return { session, storeSessionKey, migrated };
}
const unresolvedKey = storageSessionKeyForAgentScope(
scope.conversationKey,
UNRESOLVED_GLOBAL_AGENT_SCOPE,
);
if (storeSessionKey === unresolvedKey) {
return { session, storeSessionKey, migrated };
}
const unresolved = normalizeStoredSession(store.sessions[unresolvedKey]);
if (!unresolved) {
return { session, storeSessionKey, migrated };
}
const resolvedUnscopedQueue = unresolved.queue?.map((item) =>
item.agentId ? item : { ...item, agentId: scope.agentScope },
);
const merged = mergeStoredComposerSessions(session, {
...unresolved,
...(resolvedUnscopedQueue ? { queue: resolvedUnscopedQueue } : {}),
});
store.sessions[storeSessionKey] = merged;
delete store.sessions[unresolvedKey];
return { session: merged, storeSessionKey, migrated: true };
}
function parseStore(
storage: Storage,
target: ComposerStorageTarget,
raw: string,
version: 1 | 2,
): StoredComposerState | null {
try {
const parsed = JSON.parse(raw) as Partial<StoredComposerState>;
if (
!parsed ||
parsed.version !== version ||
(version === 2 && parsed.gatewayOwner !== target.gatewayOwner) ||
!parsed.sessions ||
typeof parsed.sessions !== "object"
) {
return null;
}
const sessions: Record<string, StoredComposerSession> = {};
for (const [sessionKey, value] of Object.entries(parsed.sessions)) {
const session = normalizeStoredSession(value);
if (session) {
sessions[sessionKey] = session;
}
}
const rawMainAlias = parsed.mainAlias;
const mainAlias =
rawMainAlias &&
typeof rawMainAlias === "object" &&
"key" in rawMainAlias &&
typeof rawMainAlias.key === "string" &&
rawMainAlias.key.trim() &&
"agentId" in rawMainAlias &&
typeof rawMainAlias.agentId === "string" &&
rawMainAlias.agentId.trim()
? {
key: rawMainAlias.key.trim().toLowerCase(),
agentId: normalizeAgentId(rawMainAlias.agentId),
}
: undefined;
rememberStoredMainAlias(storage, target.key, mainAlias);
return {
version: 2,
gatewayOwner: target.gatewayOwner,
sessions,
...(mainAlias ? { mainAlias } : {}),
};
} catch {
return null;
}
}
export function readStoredOutboxStore(
storage: Storage,
target: ComposerStorageTarget,
): StoredComposerState {
const raw = storage.getItem(target.key);
if (raw) {
const store = parseStore(storage, target, raw, 2);
if (store) {
return store;
}
rememberStoredMainAlias(storage, target.key, undefined);
return { version: 2, gatewayOwner: target.gatewayOwner, sessions: {} };
}
if (target.legacyOwnerIsUnambiguous) {
const legacyRaw = storage.getItem(target.legacyKey);
if (legacyRaw) {
const store = parseStore(storage, target, legacyRaw, 1);
if (store) {
try {
writeStoredOutboxStore(storage, target, store);
storage.removeItem(target.legacyKey);
} catch {
// Keep the readable v1 row when quota or privacy mode blocks migration.
}
return store;
}
}
}
rememberStoredMainAlias(storage, target.key, undefined);
return { version: 2, gatewayOwner: target.gatewayOwner, sessions: {} };
}
export function writeStoredOutboxStore(
storage: Storage,
target: ComposerStorageTarget,
store: StoredComposerState,
): void {
const entries = Object.entries(store.sessions);
const outboxes = entries.filter(([, session]) => session.queue?.length);
if (outboxes.length > MAX_STORED_SESSIONS) {
throw new Error("Chat outbox session limit reached");
}
const drafts = entries.filter(([, session]) => !session.queue?.length);
const unresolvedGlobalKey = `global\u0000agent:${UNRESOLVED_GLOBAL_AGENT_SCOPE}`;
const unresolvedGlobalDraft = drafts.find(([sessionKey]) => sessionKey === unresolvedGlobalKey);
const byNewest = (a: (typeof entries)[number], b: (typeof entries)[number]) =>
b[1].updatedAt - a[1].updatedAt ||
(b[1].draftRevision ?? 0) - (a[1].draftRevision ?? 0) ||
a[0].localeCompare(b[0]);
const clearFences = drafts
.filter(
([sessionKey, session]) =>
sessionKey !== unresolvedGlobalKey && !session.draft && session.draftRevision !== undefined,
)
.toSorted(byNewest);
// Unknown custom main aliases cannot be identified until defaults reload.
// Keep a bounded set of their clear fences, plus the canonical unresolved
// row, so eviction cannot reveal an older resolved-agent draft.
const protectedDrafts = [
...(unresolvedGlobalDraft ? [unresolvedGlobalDraft] : []),
...clearFences,
].slice(0, MAX_STORED_SESSIONS);
const ordinaryDrafts = drafts.filter(
([sessionKey, session]) => sessionKey !== unresolvedGlobalKey && Boolean(session.draft),
);
const regularSessions = [
...outboxes.toSorted(byNewest),
...ordinaryDrafts.toSorted(byNewest),
].slice(0, MAX_STORED_SESSIONS);
const retained = [...regularSessions, ...protectedDrafts];
if (retained.length === 0 && !store.mainAlias) {
storage.removeItem(target.key);
rememberStoredMainAlias(storage, target.key, undefined);
return;
}
storage.setItem(
target.key,
JSON.stringify({
version: 2,
gatewayOwner: target.gatewayOwner,
sessions: Object.fromEntries(retained),
...(store.mainAlias ? { mainAlias: store.mainAlias } : {}),
}),
);
rememberStoredMainAlias(storage, target.key, store.mainAlias);
}
export function applyStoredChatOutboxScope(
item: ChatQueueItem,
scope: ComposerStorageScope,
): ChatQueueItem {
const { agentId: _agentId, ...withoutAgentId } = item;
return {
...withoutAgentId,
sessionKey: scope.conversationKey,
...(scope.routingAgentId ? { agentId: scope.routingAgentId } : {}),
};
}
export function listStoredChatOutboxes(state: ChatComposerScope): StoredChatOutbox[] {
const storage = getSafeSessionStorage();
if (!storage) {
return [];
}
try {
const target = storageTargetForGateway(state.settings?.gatewayUrl);
const store = readStoredOutboxStore(storage, target);
const separator = "\u0000agent:";
let migrated = false;
const selectedGlobalAgentId = resolveUiKnownSelectedGlobalAgentId(state);
const defaultGlobalAgentId = hasKnownSessionDefaults(state)
? resolveUiDefaultAgentId(state)
: undefined;
if (defaultGlobalAgentId) {
const resolved = resolveStoredComposerSession(store, state, "global", defaultGlobalAgentId);
migrated = resolved.migrated;
}
if (selectedGlobalAgentId) {
const resolved = resolveStoredComposerSession(store, state, "global", selectedGlobalAgentId);
migrated = resolved.migrated || migrated;
}
for (const storeSessionKey of Object.keys(store.sessions)) {
const separatorIndex = storeSessionKey.lastIndexOf(separator);
if (separatorIndex < 0) {
continue;
}
const sessionKey = storeSessionKey.slice(0, separatorIndex);
const storedAgentScope = storeSessionKey.slice(separatorIndex + separator.length);
const resolved = resolveStoredComposerSession(
store,
state,
sessionKey,
storedAgentScope === UNRESOLVED_GLOBAL_AGENT_SCOPE ? undefined : storedAgentScope,
);
migrated = resolved.migrated || migrated;
}
if (migrated) {
try {
writeStoredOutboxStore(storage, target, store);
} catch {
// A full storage bucket must not make already-readable outboxes disappear.
}
}
const outboxes: StoredChatOutbox[] = [];
for (const [storeSessionKey, value] of Object.entries(store.sessions)) {
const separatorIndex = storeSessionKey.lastIndexOf(separator);
if (separatorIndex < 0) {
continue;
}
const sessionKey = storeSessionKey.slice(0, separatorIndex);
const agentScope = storeSessionKey.slice(separatorIndex + separator.length);
const session = normalizeStoredSession(value);
if (!session?.queue?.length) {
continue;
}
const scope = resolveComposerStorageScope(
state,
sessionKey,
agentScope === UNRESOLVED_GLOBAL_AGENT_SCOPE ? undefined : agentScope,
store.mainAlias,
);
outboxes.push({
sessionKey: scope.conversationKey,
...(scope.routingAgentId ? { agentId: scope.routingAgentId } : {}),
queue: session.queue.map((item) => applyStoredChatOutboxScope(item, scope)),
});
}
return outboxes.toSorted((left, right) => {
const createdAtDelta =
(left.queue[0]?.createdAt ?? Number.MAX_SAFE_INTEGER) -
(right.queue[0]?.createdAt ?? Number.MAX_SAFE_INTEGER);
return createdAtDelta || left.sessionKey.localeCompare(right.sessionKey);
});
} catch {
return [];
}
}
@@ -1,6 +1,7 @@
// @vitest-environment node
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ChatQueueItem } from "../../lib/chat/chat-types.ts";
import { subscribeStoredChatOutboxChanges } from "../../lib/chat/outbox-store.ts";
import { createStorageMock } from "../../test-helpers/storage.ts";
import {
admitStoredChatComposerQueueItem,
@@ -62,6 +63,44 @@ afterEach(() => {
});
describe("chat composer persistence", () => {
it("notifies durable outbox subscribers on writes until they unsubscribe", () => {
const state = createState();
const original = reconnectItem("notify", 1);
const updated = { ...original, text: "updated message" };
const listener = vi.fn();
const unsubscribe = subscribeStoredChatOutboxChanges(listener);
try {
expect(persistChatComposerState({ ...state, chatMessage: "draft only" })).toBe(true);
expect(listener).not.toHaveBeenCalled();
expect(admitStoredChatComposerQueueItem(state, state.sessionKey, original)).toBe(true);
expect(listener).toHaveBeenCalledTimes(1);
expect(
updateStoredChatComposerQueueItem(
state,
state.sessionKey,
original,
updated,
original.agentId,
),
).toBe(true);
expect(listener).toHaveBeenCalledTimes(2);
} finally {
unsubscribe();
}
expect(
removeStoredChatComposerQueueItem(
state,
state.sessionKey,
updated.id,
updated,
updated.agentId,
),
).toBe(true);
expect(listener).toHaveBeenCalledTimes(2);
});
it("flushes a debounced draft before its owner releases state", () => {
vi.useFakeTimers();
const state = createState();
File diff suppressed because it is too large Load Diff
+37
View File
@@ -0,0 +1,37 @@
import {
observeDraftRevision,
rememberDraftRevision,
} from "../../lib/chat/outbox-store-draft-state.ts";
import type { ComposerStorageTarget } from "../../lib/chat/outbox-store.ts";
import {
readStoredOutboxStore,
writeStoredOutboxStore as writeComposerStore,
type StoredComposerState,
} from "./composer-outbox-store.ts";
export { writeStoredOutboxStore as writeComposerStore } from "./composer-outbox-store.ts";
export function readComposerStore(
storage: Storage,
target: ComposerStorageTarget,
): StoredComposerState {
const hasCurrentStore = storage.getItem(target.key) !== null;
const hasLegacyStore =
!hasCurrentStore &&
target.legacyOwnerIsUnambiguous &&
storage.getItem(target.legacyKey) !== null;
const store = readStoredOutboxStore(storage, target);
for (const [sessionKey, session] of Object.entries(store.sessions)) {
observeDraftRevision(session.draftRevision);
rememberDraftRevision(storage, target.key, sessionKey, session.draftRevision);
}
if (hasLegacyStore) {
try {
writeComposerStore(storage, target, store);
storage.removeItem(target.legacyKey);
} catch {
// Keep the readable v1 row when quota or privacy mode blocks migration.
}
}
return store;
}
+6
View File
@@ -5498,6 +5498,12 @@ td.data-table-key-col {
color: var(--muted);
}
.session-row-badge--queued {
gap: 2px;
font-size: 10px;
font-variant-numeric: tabular-nums;
}
.session-row-badge--pull-request[data-pull-request-state="open"],
.session-row-badge--cloud[data-placement-state="active"] {
color: var(--ok);
@@ -364,12 +364,14 @@ describe("AppSidebar agent chip", () => {
sidebar.connected = false;
sidebar.offline = true;
sidebar.queuedOutboxCount = 3;
sidebar.lastError = "gateway unavailable?token=sidebar-secret";
await sidebar.updateComplete;
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?.textContent).toContain("3 queued");
expect(button?.getAttribute("aria-label")).toBe("Offline — Retry now — 3 queued");
expect(button?.getAttribute("aria-live")).toBe("polite");
// The redacted error detail moved from a native title to the shared
// tooltip's accessible description.
@@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
import type { GatewayBrowserClient } from "../../api/gateway.ts";
import "../../components/app-sidebar.ts";
import { createGateway, createSessions, mountSidebar } from "../app-sidebar.ts";
describe("AppSidebar outbox badges", () => {
it("shows connected session outbox counts and removes the badge when empty", async () => {
const sessionKey = "agent:main:queued-thread";
const gateway = createGateway({} as GatewayBrowserClient);
const { sidebar } = await mountSidebar(gateway, createSessions("main", [sessionKey]));
sidebar.connected = true;
sidebar.outboxCountForSession = (rowSessionKey) => (rowSessionKey === sessionKey ? 3 : 0);
await sidebar.updateComplete;
const badge = sidebar.querySelector<HTMLElement>(
`[data-session-key="${sessionKey}"] .session-row-badge--queued`,
);
expect(badge?.textContent).toContain("3");
expect(badge?.getAttribute("aria-label")).toBe("3 messages queued to send");
sidebar.outboxCountForSession = () => 0;
await sidebar.updateComplete;
expect(
sidebar.querySelector(`[data-session-key="${sessionKey}"] .session-row-badge--queued`),
).toBeNull();
});
it("resolves agent-main aliases to one queued badge count", async () => {
const gateway = createGateway({} as GatewayBrowserClient);
const { sidebar } = await mountSidebar(
gateway,
createSessions("main", ["agent:main:main"]),
"panel",
{
defaultId: "main",
mainKey: "main",
scope: "agent",
agents: [{ id: "main" }],
},
);
sidebar.outboxCountForSession = () => 3;
await sidebar.updateComplete;
const badges = sidebar.querySelectorAll(".nav-item--home .session-row-badge--queued");
expect(badges).toHaveLength(1);
expect(badges[0]?.textContent).toContain("3");
});
});
+2
View File
@@ -37,6 +37,8 @@ export type SidebarLifecycleState = HTMLElement & {
enabledRouteIds?: readonly NavigationRouteId[];
connected: boolean;
offline: boolean;
outboxCountForSession: (sessionKey: string) => number;
queuedOutboxCount: number;
lastError: string | null;
terminalAvailable: boolean;
catalogOpenTarget: "viewer" | "terminal";