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";