fix(ui): break app chat settings cycle

This commit is contained in:
Justin Edwards 2026-03-19 20:15:25 -04:00
parent d518260bb8
commit cd8fbf16b1
2 changed files with 113 additions and 5 deletions

View File

@ -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<typeof import("./storage.ts")>("./storage.ts");
return {
...actual,
saveSettings: vi.fn(),
};
});
vi.mock("./app-scroll.ts", async () => {
const actual = await vi.importActual<typeof import("./app-scroll.ts")>("./app-scroll.ts");
return {
...actual,
resetChatScroll: vi.fn(),
scheduleChatScroll: vi.fn(),
};
});
vi.mock("./app-tool-stream.ts", async () => {
const actual =
await vi.importActual<typeof import("./app-tool-stream.ts")>("./app-tool-stream.ts");
return {
...actual,
resetToolStream: vi.fn(),
};
});
function makeHost(overrides?: Partial<ChatHost>): 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<typeof sendChatMessage>[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" }),
);
});
});

View File

@ -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<typeof setLastActiveSessionKey>[0],
host.sessionKey,
);
persistLastActiveSessionKey(host, host.sessionKey);
}
if (ok && opts?.restoreDraft && opts.previousDraft?.trim()) {
host.chatMessage = opts.previousDraft;