fix(webchat): restore chat history, queue, and draft on page refresh

Fixes #51549 - WebChat loses message queue, conversation history, and draft on browser refresh

- Add chat tab loading in refreshActiveTab() to load history on reconnect
- Add persistence functions for chat queue, draft, and attachments
- Use localStorage for queue persistence (survives refresh)
- Use sessionStorage for draft and attachments (per-session)
- Persist queue when adding messages
- Restore queue, draft, and attachments on refresh

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Peter Steinberger
2026-03-24 09:34:23 +00:00
committed by Thatgfsj
parent b1b162fcdb
commit efd4b9088d
5 changed files with 204 additions and 7 deletions

View File

@@ -114,10 +114,17 @@ function resolveSlackBoltInterop(params: {
throw new TypeError("Unable to resolve @slack/bolt App/HTTPReceiver exports");
}
const { App, HTTPReceiver } = resolveSlackBoltInterop({
defaultImport: SlackBolt,
namespaceImport: SlackBoltNamespace,
});
let slackBoltInterop: SlackBoltResolvedExports | undefined;
function getSlackBoltInterop(): SlackBoltResolvedExports {
if (!slackBoltInterop) {
slackBoltInterop = resolveSlackBoltInterop({
defaultImport: SlackBolt,
namespaceImport: SlackBoltNamespace,
});
}
return slackBoltInterop;
}
const SLACK_WEBHOOK_MAX_BODY_BYTES = 1024 * 1024;
const SLACK_WEBHOOK_BODY_TIMEOUT_MS = 30_000;
@@ -250,6 +257,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
const typingReaction = slackCfg.typingReaction?.trim() ?? "";
const mediaMaxBytes = (opts.mediaMaxMb ?? slackCfg.mediaMaxMb ?? 20) * 1024 * 1024;
const removeAckAfterReply = cfg.messages?.removeAckAfterReply ?? false;
const { App, HTTPReceiver } = getSlackBoltInterop();
const receiver =
slackMode === "http"

View File

@@ -10,6 +10,15 @@ import { loadModels } from "./controllers/models.ts";
import { loadSessions } from "./controllers/sessions.ts";
import type { GatewayBrowserClient, GatewayHelloOk } from "./gateway.ts";
import { normalizeBasePath } from "./navigation.ts";
import {
clearPersistedChatQueue,
loadPersistedChatAttachments,
loadPersistedChatDraft,
loadPersistedChatQueue,
persistChatAttachments,
persistChatDraft,
persistChatQueue,
} from "./storage.ts";
import type { ChatModelOverride, ModelCatalogEntry } from "./types.ts";
import type { ChatAttachment, ChatQueueItem } from "./ui-types.ts";
import { generateUUID } from "./uuid.ts";
@@ -106,6 +115,8 @@ function enqueueChatMessage(
localCommandName: localCommand?.name,
},
];
// Persist queue to localStorage for recovery after refresh
persistChatQueue(host.sessionKey, host.chatQueue);
}
async function sendChatMessageNow(
@@ -160,9 +171,13 @@ async function flushChatQueue(host: ChatHost) {
}
const [next, ...rest] = host.chatQueue;
if (!next) {
// Queue is empty, clear persisted queue
clearPersistedChatQueue(host.sessionKey);
return;
}
host.chatQueue = rest;
// Persist updated queue (without the message being sent)
persistChatQueue(host.sessionKey, host.chatQueue);
let ok = false;
try {
if (next.localCommandName) {
@@ -179,6 +194,8 @@ async function flushChatQueue(host: ChatHost) {
}
if (!ok) {
host.chatQueue = [next, ...host.chatQueue];
// Restore persisted queue
persistChatQueue(host.sessionKey, host.chatQueue);
} else if (host.chatQueue.length > 0) {
// Continue draining — local commands don't block on server response
void flushChatQueue(host);
@@ -355,6 +372,24 @@ function injectCommandResult(host: ChatHost, content: string) {
}
export async function refreshChat(host: ChatHost, opts?: { scheduleScroll?: boolean }) {
// Restore persisted chat queue and draft before loading history
const persistedQueue = loadPersistedChatQueue(host.sessionKey);
if (persistedQueue.length > 0) {
host.chatQueue = persistedQueue as ChatQueueItem[];
}
// Restore persisted draft message
const persistedDraft = loadPersistedChatDraft(host.sessionKey);
if (persistedDraft) {
host.chatMessage = persistedDraft;
}
// Restore persisted attachments
const persistedAttachments = loadPersistedChatAttachments(host.sessionKey);
if (persistedAttachments.length > 0) {
host.chatAttachments = persistedAttachments as ChatAttachment[];
}
await Promise.all([
loadChatHistory(host as unknown as OpenClawApp),
loadSessions(host as unknown as OpenClawApp, {

View File

@@ -78,10 +78,11 @@ import {
updateSkillEdit,
updateSkillEnabled,
} from "./controllers/skills.ts";
import "./components/dashboard-header.ts";
import { buildExternalLinkRel, EXTERNAL_LINK_TARGET } from "./external-link.ts";
import "./components/dashboard-header.ts";
import { icons } from "./icons.ts";
import { normalizeBasePath, TAB_GROUPS, subtitleForTab, titleForTab } from "./navigation.ts";
import { persistChatAttachments, persistChatDraft } from "./storage.ts";
import { agentLogoUrl } from "./views/agents-utils.ts";
import {
resolveAgentConfig,
@@ -1439,10 +1440,18 @@ export function renderApp(state: AppViewState) {
},
onChatScroll: (event) => state.handleChatScroll(event),
getDraft: () => state.chatMessage,
onDraftChange: (next) => (state.chatMessage = next),
onDraftChange: (next) => {
state.chatMessage = next;
// Persist draft to sessionStorage for recovery after refresh
persistChatDraft(state.sessionKey, next);
},
onRequestUpdate: requestHostUpdate,
attachments: state.chatAttachments,
onAttachmentsChange: (next) => (state.chatAttachments = next),
onAttachmentsChange: (next) => {
state.chatAttachments = next;
// Persist attachments to sessionStorage for recovery after refresh
persistChatAttachments(state.sessionKey, next);
},
onSend: () => state.handleSendChat(),
canAbort: Boolean(state.chatRunId),
onAbort: () => void state.handleAbortChat(),

View File

@@ -214,6 +214,9 @@ export function setThemeMode(
}
export async function refreshActiveTab(host: SettingsHost) {
if (host.tab === "chat") {
await refreshChat(host as unknown as OpenClawApp);
}
if (host.tab === "overview") {
await loadOverview(host);
}

View File

@@ -343,3 +343,145 @@ function persistSettings(next: UiSettings) {
// prevent in-memory settings and visual updates from being applied
}
}
// ── Chat State Persistence ──
// Keys for persisting chat queue, draft message, and attachments across refreshes
const CHAT_QUEUE_KEY_PREFIX = "openclaw.control.chat-queue.";
const CHAT_DRAFT_KEY_PREFIX = "openclaw.control.chat-draft.";
const CHAT_ATTACHMENTS_KEY_PREFIX = "openclaw.control.chat-attachments.";
/**
* Persist the chat message queue to localStorage.
* This allows queued messages to survive browser refreshes.
*/
export function persistChatQueue(sessionKey: string, queue: Array<unknown>): void {
const storage = getSafeLocalStorage();
if (!storage) {
return;
}
try {
const key = `${CHAT_QUEUE_KEY_PREFIX}${sessionKey}`;
if (queue.length === 0) {
storage.removeItem(key);
} else {
storage.setItem(key, JSON.stringify(queue));
}
} catch {
// best-effort
}
}
/**
* Load persisted chat message queue from localStorage.
*/
export function loadPersistedChatQueue(sessionKey: string): Array<unknown> {
const storage = getSafeLocalStorage();
if (!storage) {
return [];
}
try {
const key = `${CHAT_QUEUE_KEY_PREFIX}${sessionKey}`;
const raw = storage.getItem(key);
if (!raw) {
return [];
}
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
/**
* Clear persisted chat queue for a session.
*/
export function clearPersistedChatQueue(sessionKey: string): void {
const storage = getSafeLocalStorage();
if (!storage) {
return;
}
try {
storage.removeItem(`${CHAT_QUEUE_KEY_PREFIX}${sessionKey}`);
} catch {
// best-effort
}
}
/**
* Persist unsent draft message to sessionStorage.
* sessionStorage is used because drafts should not persist across tabs/sessions.
*/
export function persistChatDraft(sessionKey: string, draft: string): void {
const storage = getSessionStorage();
if (!storage) {
return;
}
try {
const key = `${CHAT_DRAFT_KEY_PREFIX}${sessionKey}`;
if (!draft) {
storage.removeItem(key);
} else {
storage.setItem(key, draft);
}
} catch {
// best-effort
}
}
/**
* Load persisted draft message from sessionStorage.
*/
export function loadPersistedChatDraft(sessionKey: string): string {
const storage = getSessionStorage();
if (!storage) {
return "";
}
try {
const key = `${CHAT_DRAFT_KEY_PREFIX}${sessionKey}`;
return storage.getItem(key) ?? "";
} catch {
return "";
}
}
/**
* Persist unsent attachments to sessionStorage.
*/
export function persistChatAttachments(sessionKey: string, attachments: Array<unknown>): void {
const storage = getSessionStorage();
if (!storage) {
return;
}
try {
const key = `${CHAT_ATTACHMENTS_KEY_PREFIX}${sessionKey}`;
if (attachments.length === 0) {
storage.removeItem(key);
} else {
storage.setItem(key, JSON.stringify(attachments));
}
} catch {
// best-effort
}
}
/**
* Load persisted attachments from sessionStorage.
*/
export function loadPersistedChatAttachments(sessionKey: string): Array<unknown> {
const storage = getSessionStorage();
if (!storage) {
return [];
}
try {
const key = `${CHAT_ATTACHMENTS_KEY_PREFIX}${sessionKey}`;
const raw = storage.getItem(key);
if (!raw) {
return [];
}
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}