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:
@@ -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];
|
||||
}
|
||||
Reference in New Issue
Block a user