mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:40:44 +00:00
fix(ui): preserve queued chat messages across session switches (#73679)
Fixes #73621. Preserve queued Control UI chat messages across in-UI session switches by saving the active queue per session before reset and restoring it when switching back. Route the overview session selector through the shared switchChatSession helper so it follows the same queue lifecycle. Validation: - OPENCLAW_VITEST_MAX_WORKERS=1 pnpm test ui/src/ui/app-render.helpers.node.test.ts - pnpm tsgo:test:ui - pnpm exec oxfmt --check --threads=1 ui/src/ui/app-render.helpers.node.test.ts ui/src/ui/app-render.helpers.ts ui/src/ui/app-render.ts ui/src/ui/app-view-state.ts ui/src/ui/app.ts
This commit is contained in:
@@ -520,7 +520,8 @@ describe("switchChatSession", () => {
|
||||
compactionStatus: { phase: "active" },
|
||||
fallbackStatus: { phase: "active" },
|
||||
chatAvatarUrl: "/avatar/old",
|
||||
chatQueue: [{ id: "queued" }],
|
||||
chatQueue: [{ id: "queued", text: "message B", createdAt: 1 }],
|
||||
chatQueueBySession: {},
|
||||
chatRunId: "run-1",
|
||||
chatSideResultTerminalRuns: new Set(["btw-run-1"]),
|
||||
chatStreamStartedAt: 1,
|
||||
@@ -542,6 +543,10 @@ describe("switchChatSession", () => {
|
||||
switchChatSession(state, "agent:main:test-b");
|
||||
await Promise.resolve();
|
||||
|
||||
expect(state.chatQueue).toEqual([]);
|
||||
expect(state.chatQueueBySession.main).toEqual([
|
||||
{ id: "queued", text: "message B", createdAt: 1 },
|
||||
]);
|
||||
expect(state.chatSideResult).toBeNull();
|
||||
expect(state.chatSideResultTerminalRuns.size).toBe(0);
|
||||
expect(
|
||||
@@ -562,6 +567,50 @@ describe("switchChatSession", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("restores queued messages when switching back to their session", async () => {
|
||||
const settings = createSettings();
|
||||
const state = {
|
||||
sessionKey: "main",
|
||||
chatMessage: "",
|
||||
chatAttachments: [],
|
||||
chatMessages: [],
|
||||
chatToolMessages: [],
|
||||
chatStreamSegments: [],
|
||||
chatThinkingLevel: null,
|
||||
chatStream: "stream",
|
||||
chatSideResult: null,
|
||||
lastError: null,
|
||||
compactionStatus: null,
|
||||
fallbackStatus: null,
|
||||
chatAvatarUrl: null,
|
||||
chatQueue: [{ id: "queued-1", text: "message B", createdAt: 1 }],
|
||||
chatQueueBySession: {},
|
||||
chatRunId: "run-1",
|
||||
chatSideResultTerminalRuns: new Set<string>(),
|
||||
chatStreamStartedAt: 1,
|
||||
settings,
|
||||
applySettings(next: typeof settings) {
|
||||
state.settings = next;
|
||||
},
|
||||
loadAssistantIdentity: vi.fn(),
|
||||
resetToolStream: vi.fn(),
|
||||
resetChatScroll: vi.fn(),
|
||||
resetChatInputHistoryNavigation: vi.fn(),
|
||||
} as unknown as AppViewState;
|
||||
|
||||
refreshChatAvatarMock.mockResolvedValue(undefined);
|
||||
refreshSlashCommandsMock.mockResolvedValue(undefined);
|
||||
loadChatHistoryMock.mockResolvedValue(undefined);
|
||||
loadSessionsMock.mockResolvedValue(undefined);
|
||||
|
||||
switchChatSession(state, "agent:main:other");
|
||||
expect(state.chatQueue).toEqual([]);
|
||||
|
||||
switchChatSession(state, "main");
|
||||
|
||||
expect(state.chatQueue).toEqual([{ id: "queued-1", text: "message B", createdAt: 1 }]);
|
||||
});
|
||||
|
||||
it("does not force agentId=main for plain session keys", async () => {
|
||||
const settings = createSettings();
|
||||
const state = {
|
||||
@@ -579,6 +628,7 @@ describe("switchChatSession", () => {
|
||||
fallbackStatus: null,
|
||||
chatAvatarUrl: null,
|
||||
chatQueue: [],
|
||||
chatQueueBySession: {},
|
||||
chatRunId: null,
|
||||
chatSideResultTerminalRuns: new Set<string>(),
|
||||
chatStreamStartedAt: null,
|
||||
|
||||
@@ -21,6 +21,7 @@ import { parseAgentSessionKey } from "./session-key.ts";
|
||||
import { normalizeOptionalString } from "./string-coerce.ts";
|
||||
import type { ThemeMode } from "./theme.ts";
|
||||
import type { SessionsListResult } from "./types.ts";
|
||||
import type { ChatQueueItem } from "./ui-types.ts";
|
||||
|
||||
export { isCronSessionKey, parseSessionKey, resolveSessionDisplayName, resolveSessionOptionGroups };
|
||||
|
||||
@@ -66,8 +67,27 @@ function resolveSidebarChatSessionKey(state: AppViewState): string {
|
||||
return "main";
|
||||
}
|
||||
|
||||
function saveChatQueueForSession(state: AppViewState, sessionKey: string) {
|
||||
const queueBySession = (state.chatQueueBySession ??= {});
|
||||
if (state.chatQueue.length > 0) {
|
||||
queueBySession[sessionKey] = [...state.chatQueue];
|
||||
state.chatQueueBySession = { ...queueBySession };
|
||||
return;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(queueBySession, sessionKey)) {
|
||||
delete queueBySession[sessionKey];
|
||||
state.chatQueueBySession = { ...queueBySession };
|
||||
}
|
||||
}
|
||||
|
||||
function restoreChatQueueForSession(state: AppViewState, sessionKey: string): ChatQueueItem[] {
|
||||
return [...(state.chatQueueBySession?.[sessionKey] ?? [])];
|
||||
}
|
||||
|
||||
function resetChatStateForSessionSwitch(state: AppViewState, sessionKey: string) {
|
||||
const host = state as unknown as SessionSwitchHost;
|
||||
const previousSessionKey = state.sessionKey;
|
||||
saveChatQueueForSession(state, previousSessionKey);
|
||||
state.sessionKey = sessionKey;
|
||||
state.chatMessage = "";
|
||||
state.chatAttachments = [];
|
||||
@@ -84,7 +104,7 @@ function resetChatStateForSessionSwitch(state: AppViewState, sessionKey: string)
|
||||
state.chatAvatarSource = null;
|
||||
state.chatAvatarStatus = null;
|
||||
state.chatAvatarReason = null;
|
||||
state.chatQueue = [];
|
||||
state.chatQueue = restoreChatQueueForSession(state, sessionKey);
|
||||
host.resetChatInputHistoryNavigation();
|
||||
host.chatStreamStartedAt = null;
|
||||
state.chatRunId = null;
|
||||
|
||||
@@ -1565,20 +1565,7 @@ export function renderApp(state: AppViewState) {
|
||||
onSettingsChange: (next) => state.applySettings(next),
|
||||
onPasswordChange: (next) => (state.password = next),
|
||||
onSessionKeyChange: (next) => {
|
||||
state.sessionKey = next;
|
||||
state.chatMessage = "";
|
||||
state.resetChatInputHistoryNavigation();
|
||||
state.chatMessages = [];
|
||||
state.chatToolMessages = [];
|
||||
state.chatStream = null;
|
||||
state.chatRunId = null;
|
||||
state.chatQueue = [];
|
||||
state.resetToolStream();
|
||||
state.applySettings({
|
||||
...state.settings,
|
||||
sessionKey: next,
|
||||
lastActiveSessionKey: next,
|
||||
});
|
||||
switchChatSession(state, next);
|
||||
},
|
||||
onToggleGatewayTokenVisibility: () => {
|
||||
state.overviewShowGatewayToken = !state.overviewShowGatewayToken;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { EventLogEntry } from "./app-events.ts";
|
||||
import type { ChatSendOptions } from "./app-chat.ts";
|
||||
import type { EventLogEntry } from "./app-events.ts";
|
||||
import type { CompactionStatus, FallbackStatus } from "./app-tool-stream.ts";
|
||||
import type { ChatInputHistoryKeyInput, ChatInputHistoryKeyResult } from "./chat/input-history.ts";
|
||||
import type { RealtimeTalkStatus } from "./chat/realtime-talk.ts";
|
||||
@@ -108,6 +108,7 @@ export type AppViewState = {
|
||||
chatModelsLoading: boolean;
|
||||
chatModelCatalog: ModelCatalogEntry[];
|
||||
chatQueue: ChatQueueItem[];
|
||||
chatQueueBySession: Record<string, ChatQueueItem[]>;
|
||||
chatLocalInputHistoryBySession: Record<string, Array<{ text: string; ts: number }>>;
|
||||
chatInputHistorySessionKey: string | null;
|
||||
chatInputHistoryItems: string[] | null;
|
||||
|
||||
@@ -212,6 +212,7 @@ export class OpenClawApp extends LitElement {
|
||||
@state() chatModelsLoading = false;
|
||||
@state() chatModelCatalog: ModelCatalogEntry[] = [];
|
||||
@state() chatQueue: ChatQueueItem[] = [];
|
||||
@state() chatQueueBySession: Record<string, ChatQueueItem[]> = {};
|
||||
@state() chatAttachments: ChatAttachment[] = [];
|
||||
@state() realtimeTalkActive = false;
|
||||
@state() realtimeTalkStatus: RealtimeTalkStatus = "idle";
|
||||
|
||||
Reference in New Issue
Block a user