From 0bbbc99980b89d9dfc7d77b782fd609c6abe4d93 Mon Sep 17 00:00:00 2001 From: tmimmanuel <14046872+tmimmanuel@users.noreply.github.com> Date: Wed, 29 Apr 2026 08:57:04 +0200 Subject: [PATCH] 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 --- ui/src/ui/app-render.helpers.node.test.ts | 52 ++++++++++++++++++++++- ui/src/ui/app-render.helpers.ts | 22 +++++++++- ui/src/ui/app-render.ts | 15 +------ ui/src/ui/app-view-state.ts | 3 +- ui/src/ui/app.ts | 1 + 5 files changed, 76 insertions(+), 17 deletions(-) diff --git a/ui/src/ui/app-render.helpers.node.test.ts b/ui/src/ui/app-render.helpers.node.test.ts index e9280f3496c..fc12e50a997 100644 --- a/ui/src/ui/app-render.helpers.node.test.ts +++ b/ui/src/ui/app-render.helpers.node.test.ts @@ -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(), + 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(), chatStreamStartedAt: null, diff --git a/ui/src/ui/app-render.helpers.ts b/ui/src/ui/app-render.helpers.ts index 1bc8229d63a..e9cea97a9c3 100644 --- a/ui/src/ui/app-render.helpers.ts +++ b/ui/src/ui/app-render.helpers.ts @@ -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; diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index d02b0973388..be195040720 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -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; diff --git a/ui/src/ui/app-view-state.ts b/ui/src/ui/app-view-state.ts index 694907f6b18..561fea39ed3 100644 --- a/ui/src/ui/app-view-state.ts +++ b/ui/src/ui/app-view-state.ts @@ -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; chatLocalInputHistoryBySession: Record>; chatInputHistorySessionKey: string | null; chatInputHistoryItems: string[] | null; diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index 9dcb0481af7..a0c6f7aa259 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -212,6 +212,7 @@ export class OpenClawApp extends LitElement { @state() chatModelsLoading = false; @state() chatModelCatalog: ModelCatalogEntry[] = []; @state() chatQueue: ChatQueueItem[] = []; + @state() chatQueueBySession: Record = {}; @state() chatAttachments: ChatAttachment[] = []; @state() realtimeTalkActive = false; @state() realtimeTalkStatus: RealtimeTalkStatus = "idle";