Compare commits

...

6 Commits

Author SHA1 Message Date
Peter Steinberger
73422fac4a refactor: centralize webchat refresh persistence 2026-03-24 11:04:10 -07:00
Thatgfsj
d316c2cac0 Merge branch 'main' into fix/webchat-refresh-persistence 2026-03-24 21:42:46 +08:00
Thatgfsj
4340d3aa1f Merge branch 'main' into fix/webchat-refresh-persistence 2026-03-24 21:33:08 +08:00
Thatgfsj
c8cbd4960e Merge branch 'main' into fix/webchat-refresh-persistence 2026-03-24 20:40:30 +08:00
Thatgfsj
7653ebded6 trigger ci 2026-03-24 20:25:45 +08:00
Peter Steinberger
efd4b9088d 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>
2026-03-24 20:08:28 +08:00
5 changed files with 501 additions and 66 deletions

View File

@@ -12,14 +12,63 @@ vi.mock("./app-settings.ts", () => ({
}));
let handleSendChat: typeof import("./app-chat.ts").handleSendChat;
let removeQueuedMessage: typeof import("./app-chat.ts").removeQueuedMessage;
let refreshChatAvatar: typeof import("./app-chat.ts").refreshChatAvatar;
let restorePersistedChatState: typeof import("./app-chat.ts").restorePersistedChatState;
let loadPersistedChatAttachments: typeof import("./storage.ts").loadPersistedChatAttachments;
let loadPersistedChatDraft: typeof import("./storage.ts").loadPersistedChatDraft;
let loadPersistedChatQueue: typeof import("./storage.ts").loadPersistedChatQueue;
let persistChatAttachments: typeof import("./storage.ts").persistChatAttachments;
let persistChatDraft: typeof import("./storage.ts").persistChatDraft;
let persistChatQueue: typeof import("./storage.ts").persistChatQueue;
type TestChatHost = ChatHost & {
toolStreamById: Map<string, unknown>;
toolStreamOrder: string[];
chatToolMessages: unknown[];
chatStreamSegments: Array<{ text: string; ts: number }>;
toolStreamSyncTimer: number | null;
};
function createStorageMock(): Storage {
const store = new Map<string, string>();
return {
get length() {
return store.size;
},
clear() {
store.clear();
},
getItem(key: string) {
return store.get(key) ?? null;
},
key(index: number) {
return Array.from(store.keys())[index] ?? null;
},
removeItem(key: string) {
store.delete(key);
},
setItem(key: string, value: string) {
store.set(key, String(value));
},
};
}
async function loadChatHelpers(): Promise<void> {
vi.resetModules();
({ handleSendChat, refreshChatAvatar } = await import("./app-chat.ts"));
({ handleSendChat, refreshChatAvatar, removeQueuedMessage, restorePersistedChatState } =
await import("./app-chat.ts"));
({
loadPersistedChatAttachments,
loadPersistedChatDraft,
loadPersistedChatQueue,
persistChatAttachments,
persistChatDraft,
persistChatQueue,
} = await import("./storage.ts"));
}
function makeHost(overrides?: Partial<ChatHost>): ChatHost {
function makeHost(overrides?: Partial<TestChatHost>): TestChatHost {
return {
client: null,
chatMessages: [],
@@ -28,6 +77,22 @@ function makeHost(overrides?: Partial<ChatHost>): ChatHost {
chatMessage: "",
chatAttachments: [],
chatQueue: [],
settings: {
gatewayUrl: "wss://gateway.example:8443/openclaw",
token: "",
sessionKey: "agent:main",
lastActiveSessionKey: "agent:main",
theme: "claw",
themeMode: "system",
chatFocusMode: false,
chatShowThinking: true,
chatShowToolCalls: true,
splitRatio: 0.6,
navCollapsed: false,
navWidth: 220,
navGroupsCollapsed: {},
borderRadius: 50,
},
chatRunId: null,
chatSending: false,
lastError: null,
@@ -38,6 +103,11 @@ function makeHost(overrides?: Partial<ChatHost>): ChatHost {
chatModelOverrides: {},
chatModelsLoading: false,
chatModelCatalog: [],
toolStreamById: new Map(),
toolStreamOrder: [],
chatToolMessages: [],
chatStreamSegments: [],
toolStreamSyncTimer: null,
refreshSessionsAfterChat: new Set<string>(),
updateComplete: Promise.resolve(),
...overrides,
@@ -46,6 +116,8 @@ function makeHost(overrides?: Partial<ChatHost>): ChatHost {
describe("refreshChatAvatar", () => {
beforeEach(async () => {
vi.stubGlobal("localStorage", createStorageMock());
vi.stubGlobal("sessionStorage", createStorageMock());
await loadChatHelpers();
});
@@ -91,6 +163,8 @@ describe("refreshChatAvatar", () => {
describe("handleSendChat", () => {
beforeEach(async () => {
setLastActiveSessionKeyMock.mockReset();
vi.stubGlobal("localStorage", createStorageMock());
vi.stubGlobal("sessionStorage", createStorageMock());
await loadChatHelpers();
});
@@ -153,6 +227,126 @@ describe("handleSendChat", () => {
value: "openai/gpt-5-mini",
});
});
it("clears persisted draft and attachments after a successful send", async () => {
const request = vi.fn(async (method: string) => {
if (method === "chat.send") {
return {};
}
throw new Error(`Unexpected request: ${method}`);
});
const host = makeHost({
client: { request } as unknown as ChatHost["client"],
sessionKey: "main",
chatMessage: "hello",
chatAttachments: [{ id: "1", dataUrl: "data:image/png;base64,AA==", mimeType: "image/png" }],
});
persistChatDraft(host.settings.gatewayUrl, host.sessionKey, host.chatMessage);
persistChatAttachments(host.settings.gatewayUrl, host.sessionKey, host.chatAttachments);
await handleSendChat(host);
expect(loadPersistedChatDraft(host.settings.gatewayUrl, host.sessionKey)).toBe("");
expect(loadPersistedChatAttachments(host.settings.gatewayUrl, host.sessionKey)).toEqual([]);
});
it("restores persisted draft and attachments after a failed send", async () => {
const request = vi.fn(async () => {
throw new Error("send failed");
});
const attachments = [{ id: "1", dataUrl: "data:image/png;base64,AA==", mimeType: "image/png" }];
const host = makeHost({
client: { request } as unknown as ChatHost["client"],
sessionKey: "main",
chatMessage: "hello",
chatAttachments: attachments,
});
await handleSendChat(host);
expect(host.chatMessage).toBe("hello");
expect(host.chatAttachments).toEqual(attachments);
expect(loadPersistedChatDraft(host.settings.gatewayUrl, host.sessionKey)).toBe("hello");
expect(loadPersistedChatAttachments(host.settings.gatewayUrl, host.sessionKey)).toEqual(
attachments,
);
});
});
describe("chat persistence helpers", () => {
beforeEach(async () => {
vi.stubGlobal("localStorage", createStorageMock());
vi.stubGlobal("sessionStorage", createStorageMock());
await loadChatHelpers();
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("persists queue removals", () => {
const host = makeHost({
sessionKey: "main",
chatQueue: [
{ id: "keep", text: "keep", createdAt: 1 },
{ id: "drop", text: "drop", createdAt: 2 },
],
});
persistChatQueue(host.settings.gatewayUrl, host.sessionKey, host.chatQueue);
removeQueuedMessage(host, "drop");
expect(loadPersistedChatQueue(host.settings.gatewayUrl, host.sessionKey)).toEqual([
{ id: "keep", text: "keep", createdAt: 1 },
]);
});
it("restores persisted state for the active gateway only", () => {
const host = makeHost({
sessionKey: "main",
settings: {
gatewayUrl: "wss://gateway-b.example:8443/openclaw",
token: "",
sessionKey: "main",
lastActiveSessionKey: "main",
theme: "claw",
themeMode: "system",
chatFocusMode: false,
chatShowThinking: true,
chatShowToolCalls: true,
splitRatio: 0.6,
navCollapsed: false,
navWidth: 220,
navGroupsCollapsed: {},
borderRadius: 50,
},
});
persistChatQueue("wss://gateway-a.example:8443/openclaw", "main", [
{ id: "queue-a", text: "queue-a", createdAt: 1 },
]);
persistChatDraft("wss://gateway-a.example:8443/openclaw", "main", "draft-a");
persistChatAttachments("wss://gateway-a.example:8443/openclaw", "main", [
{ id: "att-a", dataUrl: "data:image/png;base64,AA==", mimeType: "image/png" },
]);
persistChatQueue(host.settings.gatewayUrl, host.sessionKey, [
{ id: "queue-b", text: "queue-b", createdAt: 2 },
]);
persistChatDraft(host.settings.gatewayUrl, host.sessionKey, "draft-b");
persistChatAttachments(host.settings.gatewayUrl, host.sessionKey, [
{ id: "att-b", dataUrl: "data:image/png;base64,AA==", mimeType: "image/png" },
]);
restorePersistedChatState(host);
expect(host.chatQueue).toEqual([{ id: "queue-b", text: "queue-b", createdAt: 2 }]);
expect(host.chatMessage).toBe("draft-b");
expect(host.chatAttachments).toEqual([
{ id: "att-b", dataUrl: "data:image/png;base64,AA==", mimeType: "image/png" },
]);
});
});
afterAll(() => {

View File

@@ -10,6 +10,17 @@ 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 {
clearPersistedChatComposer,
clearPersistedChatQueue,
loadPersistedChatAttachments,
loadPersistedChatDraft,
loadPersistedChatQueue,
persistChatAttachments,
persistChatDraft,
persistChatQueue,
type UiSettings,
} from "./storage.ts";
import type { ChatModelOverride, ModelCatalogEntry } from "./types.ts";
import type { ChatAttachment, ChatQueueItem } from "./ui-types.ts";
import { generateUUID } from "./uuid.ts";
@@ -22,6 +33,7 @@ export type ChatHost = {
chatMessage: string;
chatAttachments: ChatAttachment[];
chatQueue: ChatQueueItem[];
settings: UiSettings;
chatRunId: string | null;
chatSending: boolean;
lastError?: string | null;
@@ -38,6 +50,17 @@ export type ChatHost = {
onSlashAction?: (action: string) => void;
};
type ChatSessionStateHost = ChatHost &
Pick<OpenClawApp, "applySettings" | "resetChatScroll" | "resetToolStream"> & {
chatStreamStartedAt: number | null;
};
type PersistedChatState = {
attachments: ChatAttachment[];
draft: string;
queue: ChatQueueItem[];
};
export const CHAT_SESSIONS_ACTIVE_MINUTES = 120;
export function isChatBusy(host: ChatHost) {
@@ -78,10 +101,100 @@ export async function handleAbortChat(host: ChatHost) {
if (!host.connected) {
return;
}
host.chatMessage = "";
clearChatComposerState(host);
await abortChatRun(host as unknown as OpenClawApp);
}
function gatewayUrlForHost(host: ChatHost): string {
return host.settings.gatewayUrl;
}
function persistChatQueueState(host: ChatHost) {
persistChatQueue(gatewayUrlForHost(host), host.sessionKey, host.chatQueue);
}
export function persistChatComposerState(host: ChatHost) {
const gatewayUrl = gatewayUrlForHost(host);
persistChatDraft(gatewayUrl, host.sessionKey, host.chatMessage);
persistChatAttachments(gatewayUrl, host.sessionKey, host.chatAttachments);
}
function clearPersistedChatComposerState(host: ChatHost) {
clearPersistedChatComposer(gatewayUrlForHost(host), host.sessionKey);
}
export function setChatDraft(host: ChatHost, draft: string) {
host.chatMessage = draft;
persistChatComposerState(host);
}
export function setChatAttachments(host: ChatHost, attachments: ChatAttachment[]) {
host.chatAttachments = attachments;
persistChatComposerState(host);
}
function clearChatComposerState(host: ChatHost) {
host.chatMessage = "";
host.chatAttachments = [];
clearPersistedChatComposerState(host);
}
export function loadPersistedChatState(host: ChatHost): PersistedChatState {
const gatewayUrl = gatewayUrlForHost(host);
return {
queue: loadPersistedChatQueue(gatewayUrl, host.sessionKey),
draft: loadPersistedChatDraft(gatewayUrl, host.sessionKey),
attachments: loadPersistedChatAttachments(gatewayUrl, host.sessionKey),
};
}
function applyPersistedChatState(host: ChatHost, state: PersistedChatState) {
host.chatQueue = state.queue;
host.chatMessage = state.draft;
host.chatAttachments = state.attachments;
}
function mergePersistedChatState(host: ChatHost, state: PersistedChatState) {
if (state.queue.length > 0 || host.chatQueue.length === 0) {
host.chatQueue = state.queue;
}
if (state.draft || !host.chatMessage) {
host.chatMessage = state.draft;
}
if (state.attachments.length > 0 || host.chatAttachments.length === 0) {
host.chatAttachments = state.attachments;
}
}
export function restorePersistedChatState(
host: ChatHost,
opts?: { mode?: "merge-if-present" | "replace" },
) {
const persisted = loadPersistedChatState(host);
if (opts?.mode === "replace") {
applyPersistedChatState(host, persisted);
return;
}
mergePersistedChatState(host, persisted);
}
export function switchChatSessionState(host: ChatSessionStateHost, sessionKey: string) {
host.sessionKey = sessionKey;
host.chatStream = null;
host.chatQueue = [];
clearChatComposerState(host);
host.chatStreamStartedAt = null;
host.chatRunId = null;
host.resetToolStream();
host.resetChatScroll();
host.applySettings({
...host.settings,
sessionKey,
lastActiveSessionKey: sessionKey,
});
restorePersistedChatState(host, { mode: "replace" });
}
function enqueueChatMessage(
host: ChatHost,
text: string,
@@ -106,6 +219,7 @@ function enqueueChatMessage(
localCommandName: localCommand?.name,
},
];
persistChatQueueState(host);
}
async function sendChatMessageNow(
@@ -143,6 +257,7 @@ async function sendChatMessageNow(
if (ok && opts?.restoreAttachments && opts.previousAttachments?.length) {
host.chatAttachments = opts.previousAttachments;
}
persistChatComposerState(host);
// Force scroll after sending to ensure viewport is at bottom for incoming stream
scheduleChatScroll(host as unknown as Parameters<typeof scheduleChatScroll>[0], true);
if (ok && !host.chatRunId) {
@@ -160,9 +275,11 @@ async function flushChatQueue(host: ChatHost) {
}
const [next, ...rest] = host.chatQueue;
if (!next) {
clearPersistedChatQueue(gatewayUrlForHost(host), host.sessionKey);
return;
}
host.chatQueue = rest;
persistChatQueueState(host);
let ok = false;
try {
if (next.localCommandName) {
@@ -179,6 +296,7 @@ async function flushChatQueue(host: ChatHost) {
}
if (!ok) {
host.chatQueue = [next, ...host.chatQueue];
persistChatQueueState(host);
} else if (host.chatQueue.length > 0) {
// Continue draining — local commands don't block on server response
void flushChatQueue(host);
@@ -187,6 +305,7 @@ async function flushChatQueue(host: ChatHost) {
export function removeQueuedMessage(host: ChatHost, id: string) {
host.chatQueue = host.chatQueue.filter((item) => item.id !== id);
persistChatQueueState(host);
}
export async function handleSendChat(
@@ -217,8 +336,7 @@ export async function handleSendChat(
if (parsed?.command.executeLocal) {
if (isChatBusy(host) && shouldQueueLocalSlashCommand(parsed.command.name)) {
if (messageOverride == null) {
host.chatMessage = "";
host.chatAttachments = [];
clearChatComposerState(host);
}
enqueueChatMessage(host, message, undefined, isChatResetCommand(message), {
args: parsed.args,
@@ -228,8 +346,7 @@ export async function handleSendChat(
}
const prevDraft = messageOverride == null ? previousDraft : undefined;
if (messageOverride == null) {
host.chatMessage = "";
host.chatAttachments = [];
clearChatComposerState(host);
}
await dispatchSlashCommand(host, parsed.command.name, parsed.args, {
previousDraft: prevDraft,
@@ -240,8 +357,7 @@ export async function handleSendChat(
const refreshSessions = isChatResetCommand(message);
if (messageOverride == null) {
host.chatMessage = "";
host.chatAttachments = [];
clearChatComposerState(host);
}
if (isChatBusy(host)) {
@@ -355,6 +471,8 @@ function injectCommandResult(host: ChatHost, content: string) {
}
export async function refreshChat(host: ChatHost, opts?: { scheduleScroll?: boolean }) {
restorePersistedChatState(host);
await Promise.all([
loadChatHistory(host as unknown as OpenClawApp),
loadSessions(host as unknown as OpenClawApp, {

View File

@@ -2,7 +2,7 @@ import { html, nothing } from "lit";
import { repeat } from "lit/directives/repeat.js";
import { parseAgentSessionKey } from "../../../src/sessions/session-key-utils.js";
import { t } from "../i18n/index.ts";
import { refreshChat } from "./app-chat.ts";
import { refreshChat, refreshChatAvatar, switchChatSessionState } from "./app-chat.ts";
import { syncUrlWithSessionKey } from "./app-settings.ts";
import type { AppViewState } from "./app-view-state.ts";
import { OpenClawApp } from "./app.ts";
@@ -40,18 +40,10 @@ function resolveSidebarChatSessionKey(state: AppViewState): string {
}
function resetChatStateForSessionSwitch(state: AppViewState, sessionKey: string) {
state.sessionKey = sessionKey;
state.chatMessage = "";
state.chatStream = null;
(state as unknown as OpenClawApp).chatStreamStartedAt = null;
state.chatRunId = null;
(state as unknown as OpenClawApp).resetToolStream();
(state as unknown as OpenClawApp).resetChatScroll();
state.applySettings({
...state.settings,
switchChatSessionState(
state as unknown as Parameters<typeof switchChatSessionState>[0],
sessionKey,
lastActiveSessionKey: sessionKey,
});
);
}
export function renderTab(state: AppViewState, tab: Tab, opts?: { collapsed?: boolean }) {
@@ -486,20 +478,7 @@ export function renderChatMobileToggle(state: AppViewState) {
}
export function switchChatSession(state: AppViewState, nextSessionKey: string) {
state.sessionKey = nextSessionKey;
state.chatMessage = "";
state.chatStream = null;
// P1: Clear queued chat items from the previous session
(state as unknown as { chatQueue: unknown[] }).chatQueue = [];
(state as unknown as OpenClawApp).chatStreamStartedAt = null;
state.chatRunId = null;
(state as unknown as OpenClawApp).resetToolStream();
(state as unknown as OpenClawApp).resetChatScroll();
state.applySettings({
...state.settings,
sessionKey: nextSessionKey,
lastActiveSessionKey: nextSessionKey,
});
resetChatStateForSessionSwitch(state, nextSessionKey);
void state.loadAssistantIdentity();
syncUrlWithSessionKey(
state as unknown as Parameters<typeof syncUrlWithSessionKey>[0],
@@ -507,6 +486,7 @@ export function switchChatSession(state: AppViewState, nextSessionKey: string) {
true,
);
void loadChatHistory(state as unknown as ChatState);
void refreshChatAvatar(state as unknown as Parameters<typeof refreshChatAvatar>[0]);
void refreshSessionOptions(state);
}

View File

@@ -5,7 +5,7 @@ import {
} from "../../../src/routing/session-key.js";
import { t } from "../i18n/index.ts";
import { getSafeLocalStorage } from "../local-storage.ts";
import { refreshChatAvatar } from "./app-chat.ts";
import { refreshChatAvatar, setChatAttachments, setChatDraft } from "./app-chat.ts";
import { renderUsageTab } from "./app-render-usage-tab.ts";
import {
renderChatControls,
@@ -78,8 +78,8 @@ 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 { agentLogoUrl } from "./views/agents-utils.ts";
@@ -1385,23 +1385,7 @@ export function renderApp(state: AppViewState) {
? renderChat({
sessionKey: state.sessionKey,
onSessionKeyChange: (next) => {
state.sessionKey = next;
state.chatMessage = "";
state.chatAttachments = [];
state.chatStream = null;
state.chatStreamStartedAt = null;
state.chatRunId = null;
state.chatQueue = [];
state.resetToolStream();
state.resetChatScroll();
state.applySettings({
...state.settings,
sessionKey: next,
lastActiveSessionKey: next,
});
void state.loadAssistantIdentity();
void loadChatHistory(state);
void refreshChatAvatar(state);
switchChatSession(state, next);
},
thinkingLevel: state.chatThinkingLevel,
showThinking,
@@ -1439,10 +1423,15 @@ export function renderApp(state: AppViewState) {
},
onChatScroll: (event) => state.handleChatScroll(event),
getDraft: () => state.chatMessage,
onDraftChange: (next) => (state.chatMessage = next),
onDraftChange: (next) =>
setChatDraft(state as unknown as Parameters<typeof setChatDraft>[0], next),
onRequestUpdate: requestHostUpdate,
attachments: state.chatAttachments,
onAttachmentsChange: (next) => (state.chatAttachments = next),
onAttachmentsChange: (next) =>
setChatAttachments(
state as unknown as Parameters<typeof setChatAttachments>[0],
next,
),
onSend: () => state.handleSendChat(),
canAbort: Boolean(state.chatRunId),
onAbort: () => void state.handleAbortChat(),
@@ -1465,17 +1454,8 @@ export function renderApp(state: AppViewState) {
agentsList: state.agentsList,
currentAgentId: resolvedAgentId ?? "main",
onAgentChange: (agentId: string) => {
state.sessionKey = buildAgentMainSessionKey({ agentId });
state.chatMessages = [];
state.chatStream = null;
state.chatRunId = null;
state.applySettings({
...state.settings,
sessionKey: state.sessionKey,
lastActiveSessionKey: state.sessionKey,
});
void loadChatHistory(state);
void state.loadAssistantIdentity();
switchChatSession(state, buildAgentMainSessionKey({ agentId }));
},
onNavigateToAgent: () => {
state.agentsSelectedId = resolvedAgentId;

View File

@@ -24,6 +24,7 @@ import { isSupportedLocale } from "../i18n/index.ts";
import { getSafeLocalStorage } from "../local-storage.ts";
import { inferBasePathFromPathname, normalizeBasePath } from "./navigation.ts";
import { parseThemeSelection, type ThemeMode, type ThemeName } from "./theme.ts";
import type { ChatAttachment, ChatQueueItem } from "./ui-types.ts";
export const BORDER_RADIUS_STOPS = [0, 25, 50, 75, 100] as const;
export type BorderRadiusStop = (typeof BORDER_RADIUS_STOPS)[number];
@@ -343,3 +344,165 @@ 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.";
function chatStorageKey(prefix: string, gatewayUrl: string, sessionKey: string): string {
return `${prefix}${normalizeGatewayTokenScope(gatewayUrl)}:${sessionKey.trim() || "main"}`;
}
/**
* Persist the chat message queue to localStorage.
* This allows queued messages to survive browser refreshes.
*/
export function persistChatQueue(
gatewayUrl: string,
sessionKey: string,
queue: ChatQueueItem[],
): void {
const storage = getSafeLocalStorage();
if (!storage) {
return;
}
try {
const key = chatStorageKey(CHAT_QUEUE_KEY_PREFIX, gatewayUrl, 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(gatewayUrl: string, sessionKey: string): ChatQueueItem[] {
const storage = getSafeLocalStorage();
if (!storage) {
return [];
}
try {
const key = chatStorageKey(CHAT_QUEUE_KEY_PREFIX, gatewayUrl, 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(gatewayUrl: string, sessionKey: string): void {
const storage = getSafeLocalStorage();
if (!storage) {
return;
}
try {
storage.removeItem(chatStorageKey(CHAT_QUEUE_KEY_PREFIX, gatewayUrl, sessionKey));
} catch {
// best-effort
}
}
/**
* Persist unsent draft message to sessionStorage.
* sessionStorage is used because drafts should not persist across tabs/sessions.
*/
export function persistChatDraft(gatewayUrl: string, sessionKey: string, draft: string): void {
const storage = getSessionStorage();
if (!storage) {
return;
}
try {
const key = chatStorageKey(CHAT_DRAFT_KEY_PREFIX, gatewayUrl, sessionKey);
if (!draft) {
storage.removeItem(key);
} else {
storage.setItem(key, draft);
}
} catch {
// best-effort
}
}
/**
* Load persisted draft message from sessionStorage.
*/
export function loadPersistedChatDraft(gatewayUrl: string, sessionKey: string): string {
const storage = getSessionStorage();
if (!storage) {
return "";
}
try {
const key = chatStorageKey(CHAT_DRAFT_KEY_PREFIX, gatewayUrl, sessionKey);
return storage.getItem(key) ?? "";
} catch {
return "";
}
}
/**
* Persist unsent attachments to sessionStorage.
*/
export function persistChatAttachments(
gatewayUrl: string,
sessionKey: string,
attachments: ChatAttachment[],
): void {
const storage = getSessionStorage();
if (!storage) {
return;
}
try {
const key = chatStorageKey(CHAT_ATTACHMENTS_KEY_PREFIX, gatewayUrl, 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(
gatewayUrl: string,
sessionKey: string,
): ChatAttachment[] {
const storage = getSessionStorage();
if (!storage) {
return [];
}
try {
const key = chatStorageKey(CHAT_ATTACHMENTS_KEY_PREFIX, gatewayUrl, sessionKey);
const raw = storage.getItem(key);
if (!raw) {
return [];
}
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
export function clearPersistedChatComposer(gatewayUrl: string, sessionKey: string): void {
persistChatDraft(gatewayUrl, sessionKey, "");
persistChatAttachments(gatewayUrl, sessionKey, []);
}