mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-26 08:31:55 +00:00
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:
committed by
Thatgfsj
parent
b1b162fcdb
commit
efd4b9088d
@@ -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"
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user