Load a conversation's history on the client

The conversation pages rendered their messages on the server, so a page
the back button restored from the back-forward cache showed an old
thread. The server now renders everything else (rail, header, composer)
and the history (active session, latest messages, active task) loads
from api/admin/bots/conversation-history once the page is on screen,
with a loading line under the header, and again on a pageshow restore.
Messages sent or pushed while it loads are merged with it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-25 03:55:38 +01:00
co-authored by Claude Opus 5.5
parent cdb93ac1bc
commit d542b4e9ad
15 changed files with 485 additions and 63 deletions
@@ -0,0 +1,75 @@
import { useEffect, useRef, useState } from "react";
import type { ConversationHistory } from "@/src/types";
import fetchConversationHistory from "../(functions)/fetch-conversation-history";
type Params = {
bot_id: number;
workspace_id: number | null;
/**
* Takes each load: the thread starts over from it.
*/
onLoaded: (params: { history: ConversationHistory }) => void;
};
/**
* Loads the open conversation's history once the page is on screen, and
* again whenever the browser restores the page from its back-forward
* cache (`pageshow` with `persisted`), so going back never shows an old
* thread. Everything else on the page (the header, the composer) comes
* from the server and shows at once. `loaded` turns true with the first
* load; a conversation the user can no longer open sends them where the
* server says.
*/
export default function useConversationHistory({ bot_id, workspace_id, onLoaded }: Params) {
const [loading, setLoading] = useState(true);
const [loaded, setLoaded] = useState(false);
const [error, setError] = useState<string>();
const request = useRef(0);
const on_loaded = useRef(onLoaded);
on_loaded.current = onLoaded;
const load = async () => {
if (!bot_id) return;
const id = ++request.current;
setLoading(true);
setError(undefined);
const res = await fetchConversationHistory({ bot_id, workspace_id }).catch(() => null);
/** A newer load started meanwhile: it has the last word. */
if (id != request.current) return;
if (res?.success && res.singleRes?.redirect) {
window.location.replace(res.singleRes.redirect);
return;
}
const history = res?.success ? res.singleRes?.history : null;
setLoading(false);
if (!history?.session?.id) {
setError(res?.msg || "Couldn't load the conversation.");
return;
}
on_loaded.current({ history });
setLoaded(true);
};
useEffect(() => {
load();
const onPageShow = (event: PageTransitionEvent) => {
if (event.persisted) load();
};
window.addEventListener("pageshow", onPageShow);
return () => window.removeEventListener("pageshow", onPageShow);
}, [bot_id, workspace_id]);
return { loading, loaded, error, retry: load };
}
@@ -1,11 +1,11 @@
import { useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import postSessionApi from "@/src/functions/frontend/post-session-api";
import type { BotMessageWithAttachments, OlderBotMessagesData } from "@/src/types";
type Params = {
session_id?: number | null;
/**
* The page rendered with only the session's latest messages.
* The thread starts with only the session's latest messages.
*/
has_more: boolean;
/**
@@ -21,7 +21,8 @@ type Params = {
/**
* Loads the session's older messages a page at a time, when the thread
* is scrolled to its top. A session the thread moves onto starts with
* its first message, so it has none older.
* its first message, so it has none older; `reset` starts over from a
* loaded history instead.
*/
export default function useOlderMessages({
session_id,
@@ -32,15 +33,28 @@ export default function useOlderMessages({
const [has_more, setHasMore] = useState(initial_has_more);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string>();
const first_session = useRef(session_id);
const base_session = useRef(session_id);
useEffect(() => {
if (session_id == first_session.current) return;
if (session_id == base_session.current) return;
base_session.current = session_id;
setHasMore(false);
setError(undefined);
}, [session_id]);
/**
* A history loaded for the session: whether it has older messages.
*/
const reset = useCallback(
({ session_id, has_more }: { session_id: number; has_more: boolean }) => {
base_session.current = session_id;
setHasMore(has_more);
setError(undefined);
},
[],
);
const loadOlder = async () => {
if (loading || !has_more || !session_id || !first_message_id) return;
@@ -63,5 +77,5 @@ export default function useOlderMessages({
setHasMore(res.singleRes.has_more);
};
return { has_more, loading, error, loadOlder };
return { has_more, loading, error, loadOlder, reset };
}
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useCallback, useState } from "react";
import useServerEvent from "@/src/hooks/use-server-event";
import type { BotJobUpdateData, BotTaskWithSteps, BotUpdatedData } from "@/src/types";
@@ -9,7 +9,7 @@ type Params = {
*/
session_id?: number | null;
/**
* The bot's active task when the page loaded.
* The bot's active task when the thread started.
*/
task: BotTaskWithSteps | null;
};
@@ -30,7 +30,7 @@ function fromTask({ task }: { task: BotTaskWithSteps | null }): Progress {
* The open session's active task and its latest step (a
* `bots_queue_updates` row's text), kept current from
* `server:bot-job-update`. Starts over when `server:bot-updated` brings a
* different task, or none.
* different task, or none, or `reset` brings a loaded history's.
*/
export default function useTaskProgress({ bot_id, session_id, task }: Params) {
const [progress, setProgress] = useState<Progress>(() => fromTask({ task }));
@@ -60,5 +60,9 @@ export default function useTaskProgress({ bot_id, session_id, task }: Params) {
},
});
return progress;
const reset = useCallback(({ task }: { task: BotTaskWithSteps | null }) => {
setProgress(fromTask({ task }));
}, []);
return { ...progress, reset };
}