Add support for message history pagination, preserve scroll in rendering, and improve draft/message workflows.
CI / check (22) (push) Successful in 9m34s
CI / check (24) (push) Successful in 9m34s

This commit is contained in:
2026-10-06 00:32:18 +02:00
parent 1c0f06716e
commit 387c0ac2e7
40 changed files with 2374 additions and 1314 deletions
+60 -15
View File
@@ -18,8 +18,10 @@ export function createConversationController({
renderMessages,
updateJumpToLatest,
updateSendState,
loadSummary
loadSummary,
updateHistoryControls = () => {}
}) {
let opening = false;
function getChatById(id) { return state.chatSessions.find((chat) => chat.id === id); }
async function openCreatedChat(chat) {
@@ -35,28 +37,34 @@ export function createConversationController({
throw new Error("The newly created chat was not persisted");
}
renderChatList();
await setActiveChat(chat.id);
const opened = await setActiveChat(chat.id);
if (opened?.error) throw new Error(opened.error);
}
async function setActiveChat(id, {preserveComposer = false, matchMessageId = null} = {}) {
const failed = response => { setNotice(response.error, "error"); return response; };
const openRequest = ++state.chatOpenRequest;
if (isMobileLayout()) closeMobileSidebar();
setChatLoading(true, "Opening chat...");
setChatActivity(true, {
eyebrow: "Opening chat", title: "Loading conversation",
detail: "Fetching messages and restoring the current scene."
});
if (!opening) {
opening = true;
setChatLoading(true, "Opening chat...");
setChatActivity(true, {
eyebrow: "Opening chat", title: "Loading conversation",
detail: "Fetching messages and restoring the current scene."
});
}
try {
let res = await get(`/chats/${id}/messages`);
const historyUrl = `/chats/${id}/messages${matchMessageId ? `?around=${matchMessageId}` : ""}`;
let res = await get(historyUrl);
if (openRequest !== state.chatOpenRequest) return;
if (res.error) return setNotice(res.error, "error");
if (res.error) return failed(res);
if (matchMessageId && !res.messages?.some(message => message.id === matchMessageId)) {
const activated = await post(`/chats/${id}/branches/${matchMessageId}/activate`, {includeDescendants: true});
if (openRequest !== state.chatOpenRequest) return;
if (activated.error) return setNotice(activated.error, "error");
res = await get(`/chats/${id}/messages`);
if (activated.error) return failed(activated);
res = await get(historyUrl);
if (openRequest !== state.chatOpenRequest) return;
if (res.error) return setNotice(res.error, "error");
if (res.error) return failed(res);
}
// Commit identity, content and drafts together, only for the latest successful load.
if (!preserveComposer && id !== state.activeChatId) {
@@ -66,7 +74,11 @@ export function createConversationController({
msgInput.value = state.chatDrafts.get(id) || "";
attachmentComposer.restoreDraft(id);
resizeComposerInput();
} else if (!msgInput.value && !attachmentComposer.pending && !attachmentComposer.attachments.length) {
} else {
if (msgInput.value || attachmentComposer.pending || attachmentComposer.attachments.length) {
state.chatDrafts.set("new", msgInput.value);
attachmentComposer.saveDraft(null);
}
msgInput.value = state.chatDrafts.get(id) || "";
attachmentComposer.restoreDraft(id);
resizeComposerInput();
@@ -75,7 +87,13 @@ export function createConversationController({
const existing = getChatById(id);
if (res.chat && existing) Object.assign(existing, res.chat);
state.activeChatId = id;
attachmentComposer.adoptDraft?.(id);
if (preserveComposer) state.chatDrafts.delete("new");
state.chatDrafts.set(id, msgInput.value);
state.currentMessages = res.messages || [];
state.historyPage = res.pagination || null;
state.historyLoading = false;
updateHistoryControls();
history.replaceState(null, "", `/app/chats/${id}`);
state.messageRetryState = new Map();
updateWorkspaceCopy(); renderChatList(); updateChatActionState();
@@ -88,14 +106,39 @@ export function createConversationController({
if (target) { target.scrollIntoView({block: "center"}); updateJumpToLatest(); }
});
updateSendState();
return {ok: true};
} finally {
if (openRequest === state.chatOpenRequest) {
opening = false;
setChatLoading(false);
setChatActivity(false);
}
}
}
async function loadOlderMessages() {
if (state.historyLoading || !state.historyPage?.hasMore || !state.activeChatId) return;
const chatId = state.activeChatId, requestId = state.chatOpenRequest, page = state.historyPage;
state.historyLoading = true;
updateHistoryControls();
try {
const result = await get(`/chats/${chatId}/messages?before=${page.before}&leaf=${page.leafId}`);
if (chatId !== state.activeChatId || requestId !== state.chatOpenRequest) return;
if (result.error) { setNotice(result.error, "error"); return result; }
const oldHeight = messagesDiv.scrollHeight, oldTop = messagesDiv.scrollTop;
const existing = new Set(state.currentMessages.map(message => message.id));
state.currentMessages = [...result.messages.filter(message => !existing.has(message.id)), ...state.currentMessages];
state.historyPage = {...result.pagination, hasNewer: page.hasNewer};
renderMessages({preserveScroll: true});
messagesDiv.scrollTop = oldTop + messagesDiv.scrollHeight - oldHeight;
} finally {
if (state.historyPage === page || requestId === state.chatOpenRequest) {
state.historyLoading = false;
updateHistoryControls();
}
}
}
async function loadChatSessions() {
setChatLoading(true, "Loading chats...");
const res = await get("/chats");
@@ -169,7 +212,8 @@ export function createConversationController({
state.chatSessions.unshift(res.chat);
await loadSummary(); renderChatList();
if (!signal?.aborted && state.activeChatId === previousChatId) {
await setActiveChat(res.chat.id, {preserveComposer});
const opened = await setActiveChat(res.chat.id, {preserveComposer});
if (opened?.error) return opened;
msgInput.focus();
}
setNotice("New chat created.", "success");
@@ -185,6 +229,7 @@ export function createConversationController({
setActiveChat,
loadChatSessions,
scheduleChatSearch,
createNewChat
createNewChat,
loadOlderMessages
};
}