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 { afterEach, describe, expect, it, vi } from "vitest";
import { handleSendChat, refreshChatAvatar, type ChatHost } from "./app-chat.ts"; 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 { function makeHost(overrides?: Partial<ChatHost>): ChatHost {
return { return {
@ -71,6 +105,7 @@ describe("refreshChatAvatar", () => {
describe("handleSendChat", () => { describe("handleSendChat", () => {
afterEach(() => { afterEach(() => {
vi.unstubAllGlobals(); vi.unstubAllGlobals();
vi.clearAllMocks();
}); });
it("keeps slash-command model changes in sync with the chat header cache", async () => { 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", 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 { parseAgentSessionKey } from "../../../src/sessions/session-key-utils.js";
import { scheduleChatScroll, resetChatScroll } from "./app-scroll.ts"; import { scheduleChatScroll, resetChatScroll } from "./app-scroll.ts";
import { setLastActiveSessionKey } from "./app-settings.ts";
import { resetToolStream } from "./app-tool-stream.ts"; import { resetToolStream } from "./app-tool-stream.ts";
import type { OpenClawApp } from "./app.ts"; import type { OpenClawApp } from "./app.ts";
import { executeSlashCommand } from "./chat/slash-command-executor.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 { loadSessions } from "./controllers/sessions.ts";
import type { GatewayBrowserClient, GatewayHelloOk } from "./gateway.ts"; import type { GatewayBrowserClient, GatewayHelloOk } from "./gateway.ts";
import { normalizeBasePath } from "./navigation.ts"; import { normalizeBasePath } from "./navigation.ts";
import { saveSettings, type UiSettings } from "./storage.ts";
import type { ChatModelOverride, ModelCatalogEntry } from "./types.ts"; import type { ChatModelOverride, ModelCatalogEntry } from "./types.ts";
import type { ChatAttachment, ChatQueueItem } from "./ui-types.ts"; import type { ChatAttachment, ChatQueueItem } from "./ui-types.ts";
import { generateUUID } from "./uuid.ts"; import { generateUUID } from "./uuid.ts";
@ -38,6 +38,11 @@ export type ChatHost = {
onSlashAction?: (action: string) => void; onSlashAction?: (action: string) => void;
}; };
type ChatSessionSettingsHost = {
settings: UiSettings;
applySessionKey: string;
};
export const CHAT_SESSIONS_ACTIVE_MINUTES = 120; export const CHAT_SESSIONS_ACTIVE_MINUTES = 120;
export function isChatBusy(host: ChatHost) { export function isChatBusy(host: ChatHost) {
@ -82,6 +87,33 @@ export async function handleAbortChat(host: ChatHost) {
await abortChatRun(host as unknown as OpenClawApp); 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( function enqueueChatMessage(
host: ChatHost, host: ChatHost,
text: string, text: string,
@ -132,10 +164,7 @@ async function sendChatMessageNow(
host.chatAttachments = opts.previousAttachments; host.chatAttachments = opts.previousAttachments;
} }
if (ok) { if (ok) {
setLastActiveSessionKey( persistLastActiveSessionKey(host, host.sessionKey);
host as unknown as Parameters<typeof setLastActiveSessionKey>[0],
host.sessionKey,
);
} }
if (ok && opts?.restoreDraft && opts.previousDraft?.trim()) { if (ok && opts?.restoreDraft && opts.previousDraft?.trim()) {
host.chatMessage = opts.previousDraft; host.chatMessage = opts.previousDraft;