diff --git a/ui/src/ui/app-chat.test.ts b/ui/src/ui/app-chat.test.ts index b0df28cd947..f5d54c5bbfd 100644 --- a/ui/src/ui/app-chat.test.ts +++ b/ui/src/ui/app-chat.test.ts @@ -2,6 +2,40 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { handleSendChat, refreshChatAvatar, type ChatHost } from "./app-chat.ts"; +import { sendChatMessage } from "./controllers/chat.ts"; +import { saveSettings, type UiSettings } from "./storage.ts"; + +vi.mock("./controllers/chat.ts", () => ({ + abortChatRun: vi.fn(), + loadChatHistory: vi.fn(), + sendChatMessage: vi.fn(), +})); + +vi.mock("./storage.ts", async () => { + const actual = await vi.importActual("./storage.ts"); + return { + ...actual, + saveSettings: vi.fn(), + }; +}); + +vi.mock("./app-scroll.ts", async () => { + const actual = await vi.importActual("./app-scroll.ts"); + return { + ...actual, + resetChatScroll: vi.fn(), + scheduleChatScroll: vi.fn(), + }; +}); + +vi.mock("./app-tool-stream.ts", async () => { + const actual = + await vi.importActual("./app-tool-stream.ts"); + return { + ...actual, + resetToolStream: vi.fn(), + }; +}); function makeHost(overrides?: Partial): ChatHost { return { @@ -71,6 +105,7 @@ describe("refreshChatAvatar", () => { describe("handleSendChat", () => { afterEach(() => { vi.unstubAllGlobals(); + vi.clearAllMocks(); }); it("keeps slash-command model changes in sync with the chat header cache", async () => { @@ -128,4 +163,48 @@ describe("handleSendChat", () => { value: "openai/gpt-5-mini", }); }); + + it("persists the last active session key without depending on app-settings", async () => { + vi.mocked(sendChatMessage).mockResolvedValueOnce("run-1"); + + const settings: UiSettings = { + gatewayUrl: "ws://localhost:18789", + 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, + }; + const host = Object.assign( + makeHost({ + chatMessage: "hello", + sessionKey: "agent:ops:main", + }), + { + applySessionKey: "main", + settings, + }, + ) as ChatHost & { applySessionKey: string; settings: UiSettings }; + + await handleSendChat(host); + + expect(vi.mocked(sendChatMessage)).toHaveBeenCalledWith( + host as unknown as Parameters[0], + "hello", + undefined, + ); + expect(host.settings.lastActiveSessionKey).toBe("agent:ops:main"); + expect(host.applySessionKey).toBe("agent:ops:main"); + expect(vi.mocked(saveSettings)).toHaveBeenCalledWith( + expect.objectContaining({ lastActiveSessionKey: "agent:ops:main" }), + ); + }); }); diff --git a/ui/src/ui/app-chat.ts b/ui/src/ui/app-chat.ts index dc8eaf39be6..4478e5abca0 100644 --- a/ui/src/ui/app-chat.ts +++ b/ui/src/ui/app-chat.ts @@ -1,6 +1,5 @@ import { parseAgentSessionKey } from "../../../src/sessions/session-key-utils.js"; import { scheduleChatScroll, resetChatScroll } from "./app-scroll.ts"; -import { setLastActiveSessionKey } from "./app-settings.ts"; import { resetToolStream } from "./app-tool-stream.ts"; import type { OpenClawApp } from "./app.ts"; import { executeSlashCommand } from "./chat/slash-command-executor.ts"; @@ -10,6 +9,7 @@ import { loadModels } from "./controllers/models.ts"; import { loadSessions } from "./controllers/sessions.ts"; import type { GatewayBrowserClient, GatewayHelloOk } from "./gateway.ts"; import { normalizeBasePath } from "./navigation.ts"; +import { saveSettings, type UiSettings } from "./storage.ts"; import type { ChatModelOverride, ModelCatalogEntry } from "./types.ts"; import type { ChatAttachment, ChatQueueItem } from "./ui-types.ts"; import { generateUUID } from "./uuid.ts"; @@ -38,6 +38,11 @@ export type ChatHost = { onSlashAction?: (action: string) => void; }; +type ChatSessionSettingsHost = { + settings: UiSettings; + applySessionKey: string; +}; + export const CHAT_SESSIONS_ACTIVE_MINUTES = 120; export function isChatBusy(host: ChatHost) { @@ -82,6 +87,33 @@ export async function handleAbortChat(host: ChatHost) { await abortChatRun(host as unknown as OpenClawApp); } +function isChatSessionSettingsHost(host: ChatHost): host is ChatHost & ChatSessionSettingsHost { + return ( + "settings" in host && + typeof host.settings === "object" && + host.settings !== null && + "applySessionKey" in host && + typeof host.applySessionKey === "string" + ); +} + +function persistLastActiveSessionKey(host: ChatHost, next: string) { + if (!isChatSessionSettingsHost(host)) { + return; + } + const trimmed = next.trim(); + if (!trimmed || host.settings.lastActiveSessionKey === trimmed) { + return; + } + const settings = { + ...host.settings, + lastActiveSessionKey: trimmed, + }; + host.settings = settings; + host.applySessionKey = settings.lastActiveSessionKey; + saveSettings(settings); +} + function enqueueChatMessage( host: ChatHost, text: string, @@ -132,10 +164,7 @@ async function sendChatMessageNow( host.chatAttachments = opts.previousAttachments; } if (ok) { - setLastActiveSessionKey( - host as unknown as Parameters[0], - host.sessionKey, - ); + persistLastActiveSessionKey(host, host.sessionKey); } if (ok && opts?.restoreDraft && opts.previousDraft?.trim()) { host.chatMessage = opts.previousDraft;