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,20 @@
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
import type { APIResponseObject } from "@moduletrace/bunext/types";
import type { BotConversationRequest } from "@/src/schemas/bot-schema";
import type { ConversationHistoryPayload } from "@/src/types";
/**
* A conversation's history, loaded once the page is on screen.
*/
export default async function fetchConversationHistory({
bot_id,
workspace_id,
}: BotConversationRequest) {
return await fetchApi<
BotConversationRequest,
APIResponseObject<ConversationHistoryPayload>
>(`/api/admin/bots/conversation-history`, {
method: "POST",
body: { bot_id, workspace_id },
});
}
@@ -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 };
}
@@ -0,0 +1,23 @@
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
import Button from "@moduletrace/twui/layout/Button";
type Props = {
error: string;
onRetry: () => void;
};
/**
* Under the header when the conversation's history couldn't load: why,
* and a way to try again.
*/
export default function HistoryErrorRow({ error, onRetry }: Props) {
return (
<Row className="shrink-0 gap-3! flex-nowrap justify-center px-6 py-3 border-0 border-b border-solid border-hairline">
<Span className="text-sm text-ink-secondary">{error}</Span>
<Button title="Try again" type="button" variant="ghost" color="text" onClick={onRetry}>
Try again
</Button>
</Row>
);
}
@@ -33,6 +33,10 @@ import ConversationHeader from "../(partials)/conversation-header";
import MessageThread from "../(partials)/message-thread";
import Composer, { type ComposerHandle } from "../(partials)/composer";
import ComputerViewerModal from "../(partials)/computer-viewer-modal";
import HistoryErrorRow from "../(partials)/history-error-row";
import useConversationHistory from "../(hooks)/use-conversation-history";
import mergeLoadedHistory from "../(utils)/merge-loaded-history";
import LoadingLine from "@/src/components/general/loading-line";
/**
* The conversation column: header, bottom-anchored thread, composer, the
@@ -42,8 +46,12 @@ import ComputerViewerModal from "../(partials)/computer-viewer-modal";
* buttons in the thread, so a switch from either shows in both; either
* can open one in the viewer. Bot messages can be read aloud, one at a
* time. Text selected in the thread can be quoted in the composer, which
* mentions the crew and workspaces the user sees. The page brings the
* session's latest messages; older ones load as the thread scrolls up.
* mentions the crew and workspaces the user sees. The page brings
* everything but the history, so the header and composer show at once;
* the history (the session's latest messages and the bot's task there)
* loads on the client with a loading line under the header, and again
* when the back button restores the page. Older messages load as the
* thread scrolls up.
*/
export default function Conversation() {
const { pageProps } = useContext(AppContext);
@@ -52,7 +60,7 @@ export default function Conversation() {
const workspace_id = workspace?.id || null;
const [messages, setMessages] = useState<BotMessageWithAttachments[]>(
pageProps?.bot_messages || [],
[],
);
/**
* Messages sent here that the server hasn't confirmed yet, shown after
@@ -63,9 +71,7 @@ export default function Conversation() {
const [pending_messages, setPendingMessages] = useState<
BotMessageWithAttachments[]
>([]);
const [session_id, setSessionId] = useState<number | null>(
pageProps?.bot_session?.id || null,
);
const [session_id, setSessionId] = useState<number | null>(null);
const [has_other_unread, setHasOtherUnread] = useState(false);
const [reply_to, setReplyTo] = useState<BotMessageWithAttachments | null>(
null,
@@ -73,9 +79,19 @@ export default function Conversation() {
/**
* A quote picked in one session doesn't follow the thread to another,
* and neither does a message still on its way to the old one.
* and neither does a message still on its way to the old one. The
* first session the thread learns of isn't a move: what was sent
* before the history landed stays.
*/
const known_session = useRef<number | null>(null);
useEffect(() => {
const moved = Boolean(known_session.current);
known_session.current = session_id;
if (!moved) return;
setReplyTo(null);
setPendingMessages([]);
}, [session_id]);
@@ -108,12 +124,12 @@ export default function Conversation() {
const progress = useTaskProgress({
bot_id: bot?.id || 0,
session_id,
task: pageProps?.bot_task || null,
task: null,
});
const older = useOlderMessages({
session_id,
has_more: Boolean(pageProps?.bot_messages_more),
has_more: false,
first_message_id: Number(messages[0]?.id) || null,
onLoaded: ({ messages: older_messages }) =>
setMessages((prev) => [
@@ -124,6 +140,19 @@ export default function Conversation() {
]),
});
const history = useConversationHistory({
bot_id: Number(bot?.id) || 0,
workspace_id,
onLoaded: ({ history }) => {
const loaded_session_id = Number(history.session.id);
setSessionId(loaded_session_id);
setMessages((prev) => mergeLoadedHistory({ shown: prev, history }));
older.reset({ session_id: loaded_session_id, has_more: history.has_more });
progress.reset({ task: history.task });
},
});
const thread_messages = useMemo(
() => [...messages, ...pending_messages],
[messages, pending_messages],
@@ -169,7 +198,15 @@ export default function Conversation() {
dropSentMessage({ pending: prev, message }),
);
if (message.session_id && message.session_id != session_id) {
/**
* Before the history lands the thread has no session yet: the
* message waits in the thread, and the history merges with it.
*/
if (
session_id &&
message.session_id &&
message.session_id != session_id
) {
if (message.author == "user" && startsNewSession(message)) {
setSessionId(Number(message.session_id));
setMessages([message]);
@@ -276,6 +313,15 @@ export default function Conversation() {
workspace={workspace}
has_other_unread={has_other_unread}
/>
{history.loading ? (
<LoadingLine label="Loading the conversation" />
) : null}
{history.error ? (
<HistoryErrorRow
error={history.error}
onRetry={history.retry}
/>
) : null}
<MessageThread
bot={live_bot}
messages={thread_messages}
@@ -0,0 +1,44 @@
import { describe, expect, test } from "bun:test";
import type { BotMessageWithAttachments, ConversationHistory } from "@/src/types";
import mergeLoadedHistory from "./merge-loaded-history";
function message({ id, session_id = 1 }: { id: number; session_id?: number }) {
return { id, session_id, author: "bot", text: `#${id}` } as BotMessageWithAttachments;
}
function history({ ids, session_id = 1 }: { ids: number[]; session_id?: number }) {
return {
session: { id: session_id },
messages: ids.map((id) => message({ id, session_id })),
has_more: false,
task: null,
} as ConversationHistory;
}
const ids = (messages: BotMessageWithAttachments[]) => messages.map((m) => m.id);
describe("mergeLoadedHistory", () => {
test("an empty thread takes the history", () => {
expect(ids(mergeLoadedHistory({ shown: [], history: history({ ids: [1, 2] }) }))).toEqual([1, 2]);
});
test("keeps what arrived after the history, once", () => {
const shown = [message({ id: 2 }), message({ id: 3 })];
expect(ids(mergeLoadedHistory({ shown, history: history({ ids: [1, 2] }) }))).toEqual([1, 2, 3]);
});
test("an older copy of the thread gives way", () => {
const shown = [message({ id: 1 }), message({ id: 2 })];
expect(ids(mergeLoadedHistory({ shown, history: history({ ids: [2, 4, 5] }) }))).toEqual([2, 4, 5]);
});
test("another session's messages give way", () => {
const shown = [message({ id: 9, session_id: 1 })];
expect(
ids(mergeLoadedHistory({ shown, history: history({ ids: [10], session_id: 2 }) })),
).toEqual([10]);
});
});
@@ -0,0 +1,29 @@
import type { BotMessageWithAttachments, ConversationHistory } from "@/src/types";
type Params = {
/**
* What the thread shows now.
*/
shown: BotMessageWithAttachments[];
history: ConversationHistory;
};
/**
* The thread once a history lands: its messages, then any the thread
* already has from the same session that came after them (sent or pushed
* while the history was on its way). Everything else the thread showed
* (an older copy of the thread, another session's) gives way to it.
*/
export default function mergeLoadedHistory({ shown, history }: Params) {
const loaded = history.messages;
const last_id = Number(loaded[loaded.length - 1]?.id) || 0;
const later = shown.filter(
(message) =>
message.session_id == history.session.id &&
Number(message.id) > last_id &&
!loaded.some((m) => m.id == message.id),
);
return [...loaded, ...later];
}