diff --git a/ui/src/ui/app-chat.test.ts b/ui/src/ui/app-chat.test.ts index 9356aa8f883..c4874bc0e02 100644 --- a/ui/src/ui/app-chat.test.ts +++ b/ui/src/ui/app-chat.test.ts @@ -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; + toolStreamOrder: string[]; + chatToolMessages: unknown[]; + chatStreamSegments: Array<{ text: string; ts: number }>; + toolStreamSyncTimer: number | null; +}; + +function createStorageMock(): Storage { + const store = new Map(); + 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 { 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 { +function makeHost(overrides?: Partial): TestChatHost { return { client: null, chatMessages: [], @@ -28,6 +77,22 @@ function makeHost(overrides?: Partial): 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 { chatModelOverrides: {}, chatModelsLoading: false, chatModelCatalog: [], + toolStreamById: new Map(), + toolStreamOrder: [], + chatToolMessages: [], + chatStreamSegments: [], + toolStreamSyncTimer: null, refreshSessionsAfterChat: new Set(), updateComplete: Promise.resolve(), ...overrides, @@ -46,6 +116,8 @@ function makeHost(overrides?: Partial): 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(() => { diff --git a/ui/src/ui/app-chat.ts b/ui/src/ui/app-chat.ts index 9fd1842e55e..3ad6cee44d3 100644 --- a/ui/src/ui/app-chat.ts +++ b/ui/src/ui/app-chat.ts @@ -11,11 +11,15 @@ 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"; @@ -29,6 +33,7 @@ export type ChatHost = { chatMessage: string; chatAttachments: ChatAttachment[]; chatQueue: ChatQueueItem[]; + settings: UiSettings; chatRunId: string | null; chatSending: boolean; lastError?: string | null; @@ -45,6 +50,17 @@ export type ChatHost = { onSlashAction?: (action: string) => void; }; +type ChatSessionStateHost = ChatHost & + Pick & { + chatStreamStartedAt: number | null; + }; + +type PersistedChatState = { + attachments: ChatAttachment[]; + draft: string; + queue: ChatQueueItem[]; +}; + export const CHAT_SESSIONS_ACTIVE_MINUTES = 120; export function isChatBusy(host: ChatHost) { @@ -85,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, @@ -113,8 +219,7 @@ function enqueueChatMessage( localCommandName: localCommand?.name, }, ]; - // Persist queue to localStorage for recovery after refresh - persistChatQueue(host.sessionKey, host.chatQueue); + persistChatQueueState(host); } async function sendChatMessageNow( @@ -152,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[0], true); if (ok && !host.chatRunId) { @@ -169,13 +275,11 @@ async function flushChatQueue(host: ChatHost) { } const [next, ...rest] = host.chatQueue; if (!next) { - // Queue is empty, clear persisted queue - clearPersistedChatQueue(host.sessionKey); + clearPersistedChatQueue(gatewayUrlForHost(host), host.sessionKey); return; } host.chatQueue = rest; - // Persist updated queue (without the message being sent) - persistChatQueue(host.sessionKey, host.chatQueue); + persistChatQueueState(host); let ok = false; try { if (next.localCommandName) { @@ -192,8 +296,7 @@ async function flushChatQueue(host: ChatHost) { } if (!ok) { host.chatQueue = [next, ...host.chatQueue]; - // Restore persisted queue - persistChatQueue(host.sessionKey, host.chatQueue); + persistChatQueueState(host); } else if (host.chatQueue.length > 0) { // Continue draining — local commands don't block on server response void flushChatQueue(host); @@ -202,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( @@ -232,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, @@ -243,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, @@ -255,8 +357,7 @@ export async function handleSendChat( const refreshSessions = isChatResetCommand(message); if (messageOverride == null) { - host.chatMessage = ""; - host.chatAttachments = []; + clearChatComposerState(host); } if (isChatBusy(host)) { @@ -370,23 +471,7 @@ 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[]; - } + restorePersistedChatState(host); await Promise.all([ loadChatHistory(host as unknown as OpenClawApp), diff --git a/ui/src/ui/app-render.helpers.ts b/ui/src/ui/app-render.helpers.ts index 889b6972cde..b97f296ca69 100644 --- a/ui/src/ui/app-render.helpers.ts +++ b/ui/src/ui/app-render.helpers.ts @@ -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[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[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[0]); void refreshSessionOptions(state); } diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index 9ca194174e0..62af4317394 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -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, @@ -82,7 +82,6 @@ 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, @@ -1386,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, @@ -1440,18 +1423,15 @@ export function renderApp(state: AppViewState) { }, onChatScroll: (event) => state.handleChatScroll(event), getDraft: () => state.chatMessage, - onDraftChange: (next) => { - state.chatMessage = next; - // Persist draft to sessionStorage for recovery after refresh - persistChatDraft(state.sessionKey, next); - }, + onDraftChange: (next) => + setChatDraft(state as unknown as Parameters[0], next), onRequestUpdate: requestHostUpdate, attachments: state.chatAttachments, - onAttachmentsChange: (next) => { - state.chatAttachments = next; - // Persist attachments to sessionStorage for recovery after refresh - persistChatAttachments(state.sessionKey, next); - }, + onAttachmentsChange: (next) => + setChatAttachments( + state as unknown as Parameters[0], + next, + ), onSend: () => state.handleSendChat(), canAbort: Boolean(state.chatRunId), onAbort: () => void state.handleAbortChat(), @@ -1474,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; diff --git a/ui/src/ui/app-settings.ts b/ui/src/ui/app-settings.ts index af47cb32e3e..6532f08cec4 100644 --- a/ui/src/ui/app-settings.ts +++ b/ui/src/ui/app-settings.ts @@ -214,9 +214,6 @@ 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); } diff --git a/ui/src/ui/storage.ts b/ui/src/ui/storage.ts index cec26f35ff3..cfa6789b0be 100644 --- a/ui/src/ui/storage.ts +++ b/ui/src/ui/storage.ts @@ -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]; @@ -351,17 +352,25 @@ 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(sessionKey: string, queue: Array): void { +export function persistChatQueue( + gatewayUrl: string, + sessionKey: string, + queue: ChatQueueItem[], +): void { const storage = getSafeLocalStorage(); if (!storage) { return; } try { - const key = `${CHAT_QUEUE_KEY_PREFIX}${sessionKey}`; + const key = chatStorageKey(CHAT_QUEUE_KEY_PREFIX, gatewayUrl, sessionKey); if (queue.length === 0) { storage.removeItem(key); } else { @@ -375,13 +384,13 @@ export function persistChatQueue(sessionKey: string, queue: Array): voi /** * Load persisted chat message queue from localStorage. */ -export function loadPersistedChatQueue(sessionKey: string): Array { +export function loadPersistedChatQueue(gatewayUrl: string, sessionKey: string): ChatQueueItem[] { const storage = getSafeLocalStorage(); if (!storage) { return []; } try { - const key = `${CHAT_QUEUE_KEY_PREFIX}${sessionKey}`; + const key = chatStorageKey(CHAT_QUEUE_KEY_PREFIX, gatewayUrl, sessionKey); const raw = storage.getItem(key); if (!raw) { return []; @@ -396,13 +405,13 @@ export function loadPersistedChatQueue(sessionKey: string): Array { /** * Clear persisted chat queue for a session. */ -export function clearPersistedChatQueue(sessionKey: string): void { +export function clearPersistedChatQueue(gatewayUrl: string, sessionKey: string): void { const storage = getSafeLocalStorage(); if (!storage) { return; } try { - storage.removeItem(`${CHAT_QUEUE_KEY_PREFIX}${sessionKey}`); + storage.removeItem(chatStorageKey(CHAT_QUEUE_KEY_PREFIX, gatewayUrl, sessionKey)); } catch { // best-effort } @@ -412,13 +421,13 @@ export function clearPersistedChatQueue(sessionKey: string): void { * 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 { +export function persistChatDraft(gatewayUrl: string, sessionKey: string, draft: string): void { const storage = getSessionStorage(); if (!storage) { return; } try { - const key = `${CHAT_DRAFT_KEY_PREFIX}${sessionKey}`; + const key = chatStorageKey(CHAT_DRAFT_KEY_PREFIX, gatewayUrl, sessionKey); if (!draft) { storage.removeItem(key); } else { @@ -432,13 +441,13 @@ export function persistChatDraft(sessionKey: string, draft: string): void { /** * Load persisted draft message from sessionStorage. */ -export function loadPersistedChatDraft(sessionKey: string): string { +export function loadPersistedChatDraft(gatewayUrl: string, sessionKey: string): string { const storage = getSessionStorage(); if (!storage) { return ""; } try { - const key = `${CHAT_DRAFT_KEY_PREFIX}${sessionKey}`; + const key = chatStorageKey(CHAT_DRAFT_KEY_PREFIX, gatewayUrl, sessionKey); return storage.getItem(key) ?? ""; } catch { return ""; @@ -448,13 +457,17 @@ export function loadPersistedChatDraft(sessionKey: string): string { /** * Persist unsent attachments to sessionStorage. */ -export function persistChatAttachments(sessionKey: string, attachments: Array): void { +export function persistChatAttachments( + gatewayUrl: string, + sessionKey: string, + attachments: ChatAttachment[], +): void { const storage = getSessionStorage(); if (!storage) { return; } try { - const key = `${CHAT_ATTACHMENTS_KEY_PREFIX}${sessionKey}`; + const key = chatStorageKey(CHAT_ATTACHMENTS_KEY_PREFIX, gatewayUrl, sessionKey); if (attachments.length === 0) { storage.removeItem(key); } else { @@ -468,13 +481,16 @@ export function persistChatAttachments(sessionKey: string, attachments: Array { +export function loadPersistedChatAttachments( + gatewayUrl: string, + sessionKey: string, +): ChatAttachment[] { const storage = getSessionStorage(); if (!storage) { return []; } try { - const key = `${CHAT_ATTACHMENTS_KEY_PREFIX}${sessionKey}`; + const key = chatStorageKey(CHAT_ATTACHMENTS_KEY_PREFIX, gatewayUrl, sessionKey); const raw = storage.getItem(key); if (!raw) { return []; @@ -485,3 +501,8 @@ export function loadPersistedChatAttachments(sessionKey: string): Array return []; } } + +export function clearPersistedChatComposer(gatewayUrl: string, sessionKey: string): void { + persistChatDraft(gatewayUrl, sessionKey, ""); + persistChatAttachments(gatewayUrl, sessionKey, []); +}