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:
tmimmanuel
2026-04-29 08:57:04 +02:00
committed by GitHub
parent 20c7a98fb8
commit 0bbbc99980
5 changed files with 76 additions and 17 deletions

View File

@@ -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,

View File

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

View File

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

View File

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

View File

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