openclaw/ui/src/ui/app-chat.ts
Tyler Yust 6372242da7
fix(ui): improve chat session dropdown and refresh behavior (#3682)
* refactor(ui): enhance loadSessions function to accept overrides for session loading parameters

- Updated loadSessions to include optional parameters for activeMinutes, limit, includeGlobal, and includeUnknown.
- Modified refreshChat to use the new activeMinutes parameter when loading sessions.
- Removed duplicate applySettingsFromUrl call in handleConnected function.

* feat(ui): implement session refresh functionality after chat

- Added `refreshSessionsAfterChat` property to `ChatHost` and `GatewayHost` types.
- Introduced `isChatResetCommand` function to identify chat reset commands.
- Updated `handleSendChat` to set `refreshSessions` based on chat reset commands.
- Modified `handleGatewayEventUnsafe` to load sessions when chat is finalized and `refreshSessionsAfterChat` is true.
- Enhanced `refreshChat` to load sessions with `activeMinutes` set to 0 for immediate refresh.
2026-01-28 23:24:46 -08:00

224 lines
7.1 KiB
TypeScript

import { abortChatRun, loadChatHistory, sendChatMessage } from "./controllers/chat";
import { loadSessions } from "./controllers/sessions";
import { generateUUID } from "./uuid";
import { resetToolStream } from "./app-tool-stream";
import { scheduleChatScroll } from "./app-scroll";
import { setLastActiveSessionKey } from "./app-settings";
import { normalizeBasePath } from "./navigation";
import type { GatewayHelloOk } from "./gateway";
import { parseAgentSessionKey } from "../../../src/sessions/session-key-utils.js";
import type { MoltbotApp } from "./app";
import type { ChatAttachment, ChatQueueItem } from "./ui-types";
type ChatHost = {
connected: boolean;
chatMessage: string;
chatAttachments: ChatAttachment[];
chatQueue: ChatQueueItem[];
chatRunId: string | null;
chatSending: boolean;
sessionKey: string;
basePath: string;
hello: GatewayHelloOk | null;
chatAvatarUrl: string | null;
refreshSessionsAfterChat: boolean;
};
export function isChatBusy(host: ChatHost) {
return host.chatSending || Boolean(host.chatRunId);
}
export function isChatStopCommand(text: string) {
const trimmed = text.trim();
if (!trimmed) return false;
const normalized = trimmed.toLowerCase();
if (normalized === "/stop") return true;
return (
normalized === "stop" ||
normalized === "esc" ||
normalized === "abort" ||
normalized === "wait" ||
normalized === "exit"
);
}
function isChatResetCommand(text: string) {
const trimmed = text.trim();
if (!trimmed) return false;
const normalized = trimmed.toLowerCase();
if (normalized === "/new" || normalized === "/reset") return true;
return normalized.startsWith("/new ") || normalized.startsWith("/reset ");
}
export async function handleAbortChat(host: ChatHost) {
if (!host.connected) return;
host.chatMessage = "";
await abortChatRun(host as unknown as MoltbotApp);
}
function enqueueChatMessage(host: ChatHost, text: string, attachments?: ChatAttachment[]) {
const trimmed = text.trim();
const hasAttachments = Boolean(attachments && attachments.length > 0);
if (!trimmed && !hasAttachments) return;
host.chatQueue = [
...host.chatQueue,
{
id: generateUUID(),
text: trimmed,
createdAt: Date.now(),
attachments: hasAttachments ? attachments?.map((att) => ({ ...att })) : undefined,
},
];
}
async function sendChatMessageNow(
host: ChatHost,
message: string,
opts?: {
previousDraft?: string;
restoreDraft?: boolean;
attachments?: ChatAttachment[];
previousAttachments?: ChatAttachment[];
restoreAttachments?: boolean;
refreshSessions?: boolean;
},
) {
resetToolStream(host as unknown as Parameters<typeof resetToolStream>[0]);
const ok = await sendChatMessage(host as unknown as MoltbotApp, message, opts?.attachments);
if (!ok && opts?.previousDraft != null) {
host.chatMessage = opts.previousDraft;
}
if (!ok && opts?.previousAttachments) {
host.chatAttachments = opts.previousAttachments;
}
if (ok) {
setLastActiveSessionKey(host as unknown as Parameters<typeof setLastActiveSessionKey>[0], host.sessionKey);
}
if (ok && opts?.restoreDraft && opts.previousDraft?.trim()) {
host.chatMessage = opts.previousDraft;
}
if (ok && opts?.restoreAttachments && opts.previousAttachments?.length) {
host.chatAttachments = opts.previousAttachments;
}
scheduleChatScroll(host as unknown as Parameters<typeof scheduleChatScroll>[0]);
if (ok && !host.chatRunId) {
void flushChatQueue(host);
}
if (ok && opts?.refreshSessions) {
host.refreshSessionsAfterChat = true;
}
return ok;
}
async function flushChatQueue(host: ChatHost) {
if (!host.connected || isChatBusy(host)) return;
const [next, ...rest] = host.chatQueue;
if (!next) return;
host.chatQueue = rest;
const ok = await sendChatMessageNow(host, next.text, { attachments: next.attachments });
if (!ok) {
host.chatQueue = [next, ...host.chatQueue];
}
}
export function removeQueuedMessage(host: ChatHost, id: string) {
host.chatQueue = host.chatQueue.filter((item) => item.id !== id);
}
export async function handleSendChat(
host: ChatHost,
messageOverride?: string,
opts?: { restoreDraft?: boolean },
) {
if (!host.connected) return;
const previousDraft = host.chatMessage;
const message = (messageOverride ?? host.chatMessage).trim();
const attachments = host.chatAttachments ?? [];
const attachmentsToSend = messageOverride == null ? attachments : [];
const hasAttachments = attachmentsToSend.length > 0;
// Allow sending with just attachments (no message text required)
if (!message && !hasAttachments) return;
if (isChatStopCommand(message)) {
await handleAbortChat(host);
return;
}
const refreshSessions = isChatResetCommand(message);
if (messageOverride == null) {
host.chatMessage = "";
// Clear attachments when sending
host.chatAttachments = [];
}
if (isChatBusy(host)) {
enqueueChatMessage(host, message, attachmentsToSend);
return;
}
await sendChatMessageNow(host, message, {
previousDraft: messageOverride == null ? previousDraft : undefined,
restoreDraft: Boolean(messageOverride && opts?.restoreDraft),
attachments: hasAttachments ? attachmentsToSend : undefined,
previousAttachments: messageOverride == null ? attachments : undefined,
restoreAttachments: Boolean(messageOverride && opts?.restoreDraft),
refreshSessions,
});
}
export async function refreshChat(host: ChatHost) {
await Promise.all([
loadChatHistory(host as unknown as MoltbotApp),
loadSessions(host as unknown as MoltbotApp, { activeMinutes: 0 }),
refreshChatAvatar(host),
]);
scheduleChatScroll(host as unknown as Parameters<typeof scheduleChatScroll>[0], true);
}
export const flushChatQueueForEvent = flushChatQueue;
type SessionDefaultsSnapshot = {
defaultAgentId?: string;
};
function resolveAgentIdForSession(host: ChatHost): string | null {
const parsed = parseAgentSessionKey(host.sessionKey);
if (parsed?.agentId) return parsed.agentId;
const snapshot = host.hello?.snapshot as { sessionDefaults?: SessionDefaultsSnapshot } | undefined;
const fallback = snapshot?.sessionDefaults?.defaultAgentId?.trim();
return fallback || "main";
}
function buildAvatarMetaUrl(basePath: string, agentId: string): string {
const base = normalizeBasePath(basePath);
const encoded = encodeURIComponent(agentId);
return base ? `${base}/avatar/${encoded}?meta=1` : `/avatar/${encoded}?meta=1`;
}
export async function refreshChatAvatar(host: ChatHost) {
if (!host.connected) {
host.chatAvatarUrl = null;
return;
}
const agentId = resolveAgentIdForSession(host);
if (!agentId) {
host.chatAvatarUrl = null;
return;
}
host.chatAvatarUrl = null;
const url = buildAvatarMetaUrl(host.basePath, agentId);
try {
const res = await fetch(url, { method: "GET" });
if (!res.ok) {
host.chatAvatarUrl = null;
return;
}
const data = (await res.json()) as { avatarUrl?: unknown };
const avatarUrl = typeof data.avatarUrl === "string" ? data.avatarUrl.trim() : "";
host.chatAvatarUrl = avatarUrl || null;
} catch {
host.chatAvatarUrl = null;
}
}