mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-27 09:21:35 +07:00
refactor: centralize webchat refresh persistence
This commit is contained in:
@@ -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(() => {
|
||||
|
||||
@@ -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<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) {
|
||||
@@ -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<typeof scheduleChatScroll>[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),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<typeof setChatDraft>[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<typeof setChatAttachments>[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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<unknown>): 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<unknown>): voi
|
||||
/**
|
||||
* Load persisted chat message queue from localStorage.
|
||||
*/
|
||||
export function loadPersistedChatQueue(sessionKey: string): Array<unknown> {
|
||||
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<unknown> {
|
||||
/**
|
||||
* 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<unknown>): 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<un
|
||||
/**
|
||||
* Load persisted attachments from sessionStorage.
|
||||
*/
|
||||
export function loadPersistedChatAttachments(sessionKey: string): Array<unknown> {
|
||||
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<unknown>
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function clearPersistedChatComposer(gatewayUrl: string, sessionKey: string): void {
|
||||
persistChatDraft(gatewayUrl, sessionKey, "");
|
||||
persistChatAttachments(gatewayUrl, sessionKey, []);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user