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
+33
View File
@@ -0,0 +1,33 @@
import Stack from "@moduletrace/twui/layout/Stack";
import Span from "@moduletrace/twui/layout/Span";
import { twMerge } from "tailwind-merge";
type Props = {
label?: string;
className?: string;
};
/**
* A thin line sweeping across, set just under a pane header while its
* page loads. Takes no height (it overlays the top of what follows), so
* nothing moves when it goes. Under reduced motion it holds still.
*/
export default function LoadingLine({ label = "Loading", className }: Props) {
return (
<Stack
role="progressbar"
aria-label={label}
className={twMerge("relative h-0 w-full shrink-0 gap-0! z-10", className)}
>
<Stack className="absolute inset-x-0 top-0 h-0.5 gap-0! overflow-hidden bg-ink/5">
<Span
className={twMerge(
"block h-full w-1/3 bg-linear-to-r from-transparent via-ink to-transparent",
"animate-loading-line",
"motion-reduce:animate-none motion-reduce:w-full motion-reduce:opacity-30",
)}
/>
</Stack>
</Stack>
);
}
@@ -0,0 +1,67 @@
import { AppData } from "@/src/data/app-data";
import type { ConversationHistory } from "@/src/types";
import grabActiveBotTask from "../bots/grab-active-bot-task";
import grabBotMessages from "../bots/grab-bot-messages";
import ensureBotSession from "./ensure-bot-session";
import grabUserConversation from "./grab-user-conversation";
import markBotSessionRead from "./mark-bot-session-read";
type Params = {
user_id: number;
bot_id: number | string;
/**
* The workspace the conversation is in; none for the bot's own chat.
*/
workspace_id?: number | string | null;
};
/**
* A conversation's history, which the conversation pages load on the
* client once they render (`api/admin/bots/conversation-history`): the
* bot's active session there (marked read), its latest
* `AppData.ConversationFirstMessages` messages (the thread loads older
* ones as it scrolls) and the bot's active task in it. Everything else on
* the page comes with it from the server (`grabConversationProps`).
* Null when the user can't chat with the bot there.
*/
export default async function grabConversationHistory({
user_id,
bot_id,
workspace_id,
}: Params): Promise<ConversationHistory | null> {
const conversation = await grabUserConversation({ user_id, bot_id, workspace_id }).catch(
() => null,
);
if (!conversation?.bot?.id) return null;
const open_bot_id = conversation.bot.id;
const session = await ensureBotSession({
user_id,
bot_id: open_bot_id,
workspace_id: conversation.workspace_id,
});
if (session.unread) {
await markBotSessionRead({ session_id: session.id!, user_id });
session.unread = 0;
}
const [messages, task] = await Promise.all([
grabBotMessages({
session_id: session.id!,
user_id,
limit: AppData.ConversationFirstMessages + 1,
}),
grabActiveBotTask({ bot_id: open_bot_id, session_id: session.id }),
]);
const has_more = messages.length > AppData.ConversationFirstMessages;
return {
session,
messages: has_more ? messages.slice(1) : messages,
has_more,
task,
};
}
@@ -1,19 +1,14 @@
import type { BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
import { AppData } from "@/src/data/app-data";
import type { BotTextModel, PagePropsType } from "@/src/types";
import railBots from "@/src/utils/rail-bots";
import railWorkspaces from "@/src/utils/rail-workspaces";
import grabBots from "../bots/grab-bots";
import grabActiveBotTask from "../bots/grab-active-bot-task";
import grabBotMessages from "../bots/grab-bot-messages";
import grabBotExtensionsOverview from "../extensions/grab-bot-extensions-overview";
import grabConversationFiles from "../bot-files/grab-conversation-files";
import grabBotComputers from "../computers/grab-bot-computers";
import resolveHarnessCandidates from "@/src/harness/resolve/resolve-harness-candidates";
import grabUserWorkspaces from "../workspaces/grab-user-workspaces";
import ensureBotSession from "./ensure-bot-session";
import grabUserConversation from "./grab-user-conversation";
import markBotSessionRead from "./mark-bot-session-read";
type Params = {
user_id: number;
@@ -27,11 +22,12 @@ type Params = {
/**
* The page props of a conversation (`/admin/bots/<id>` or
* `/admin/workspaces/<id>/bots/<id>`): the rail's crew (the workspace's
* in a workspace) and workspaces, the bot, its active session (marked
* read) and its latest `AppData.ConversationFirstMessages` messages (the
* thread loads older ones as it scrolls), the text model it runs on, its computers,
* tools and MCP servers and active task there, and whether its mic and
* read-aloud have a model to run on. The full model settings are left to
* in a workspace) and workspaces, the bot, the text model it runs on, its
* computers, tools, MCP servers and files there, and whether its mic and
* read-aloud have a model to run on. The history (its session, messages
* and active task) is left to the client (`grabConversationHistory`,
* `api/admin/bots/conversation-history`), so a page the back button
* restores never shows an old thread. The full model settings are left to
* the models modal: the install-wide catalog (`api/admin/harness/catalog`)
* and the conversation's small setting
* (`api/admin/bots/harness-overview`). They are most of a page's weight
@@ -50,12 +46,6 @@ export default async function grabConversationProps({
if (!conversation) return null;
const scope = conversation.workspace_id;
const session = await ensureBotSession({ user_id, bot_id: conversation.bot.id, workspace_id: scope });
if (session.unread) {
await markBotSessionRead({ session_id: session.id!, user_id });
session.unread = 0;
}
const [bots, workspaces] = await Promise.all([
grabBots({ user_id, workspace_id: scope }),
@@ -66,36 +56,25 @@ export default async function grabConversationProps({
if (!bot?.id) return null;
/** Loading its history marks the open session read. */
bot.unread = 0;
const [bot_messages, bot_text_model, bot_computers, bot_task, bot_extensions, bot_files, transcription, speech] = await Promise.all([
grabBotMessages({
session_id: session.id!,
user_id,
limit: AppData.ConversationFirstMessages + 1,
}),
const [bot_text_model, bot_computers, bot_extensions, bot_files, transcription, speech] = await Promise.all([
grabTextModel({ bot_id: bot.id, workspace_id: scope }),
grabBotComputers({ bot_id: bot.id, workspace_id: scope }),
grabActiveBotTask({ bot_id: bot.id, session_id: session.id }),
grabBotExtensionsOverview({ user_id, bot_id: bot.id, workspace_id: scope }),
grabConversationFiles({ bot_id: bot.id, workspace_id: scope }),
hasHarnessModel({ harness: "transcription", bot_id: bot.id, workspace_id: scope }),
hasHarnessModel({ harness: "speech", bot_id: bot.id, workspace_id: scope }),
]);
const bot_messages_more = bot_messages.length > AppData.ConversationFirstMessages;
return {
bots: railBots({ bots }),
bot: railBots({ bots: [bot] })[0],
workspaces: railWorkspaces({ workspaces }),
workspace: conversation.workspace ? withoutContext(conversation.workspace) : null,
bot_session: session,
bot_messages: bot_messages_more ? bot_messages.slice(1) : bot_messages,
bot_messages_more,
bot_text_model,
bot_computers,
bot_task,
bot_extensions,
bot_files,
bot_voice: { transcription, speech },
@@ -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];
}
@@ -0,0 +1,62 @@
import userAuth from "@/src/functions/backend/auth/user-auth";
import grabConversationHistory from "@/src/functions/backend/db/bot-sessions/grab-conversation-history";
import { BotConversationRequestSchema } from "@/src/schemas/bot-schema";
import type { ConversationHistoryPayload } from "@/src/types";
import AppError from "@/src/utils/app-error";
import parseWithSchema from "@/src/utils/parse-with-schema";
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
import safeErrorMessage from "@/src/utils/safe-error-message";
import type {
APIResponseObject,
BunextAPIRouteHandler,
} from "@moduletrace/bunext/types";
/**
* A conversation's history (`grabConversationHistory`: its active session,
* latest messages and the bot's active task there), which the
* conversation pages load once they are on screen instead of rendering it
* on the server, so a page restored by the back button never shows an
* old thread. When the user can't chat with the bot there any more,
* `redirect` says where the page goes instead: their bots, or the
* workspace (its settings for managers).
*/
export const handler: BunextAPIRouteHandler<
APIResponseObject<ConversationHistoryPayload>
> = async ({ req, body }) => {
try {
if (req.method != "POST") {
throw new AppError(`Method not allowed`);
}
const { user, user_types } = await userAuth({ req });
if (!user?.logged_in_status || !user.id) {
return { success: false, msg: `Unauthorized`, logoutUser: true };
}
const { bot_id, workspace_id } = parseWithSchema({
schema: BotConversationRequestSchema,
data: body,
});
const history = await grabConversationHistory({
user_id: Number(user.id),
bot_id,
workspace_id,
});
if (!history) {
const redirect = !workspace_id
? "/admin/bots"
: canManageCrew({ user_types })
? `/admin/settings/workspaces/${workspace_id}`
: `/admin/workspaces/${workspace_id}`;
return { success: true, singleRes: { redirect } };
}
return { success: true, singleRes: { history } };
} catch (error: unknown) {
return { success: false, msg: safeErrorMessage({ error }) };
}
};
+13
View File
@@ -184,6 +184,19 @@
/* The rail's full (named) width: below it the rail shows icons only.
A container query, so the mobile drawer opens full too. */
--container-rail: 220px;
/* The thin line under a pane header while its page loads
(`LoadingLine`): a short bar sweeping across, again and again. */
--animate-loading-line: loading-line 1.2s ease-in-out infinite;
@keyframes loading-line {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(300%);
}
}
}
body {
+26 -13
View File
@@ -1080,6 +1080,32 @@ export type BotTextModel = {
provider_name: string;
};
/**
* A conversation's history, which its page loads on the client once it
* renders (`grabConversationHistory`): the active session, its latest
* messages, whether it has older ones, and the bot's active task in it.
*/
export type ConversationHistory = {
session: BUN_SQLITE_COBBLE_BOT_SESSIONS;
messages: BotMessageWithAttachments[];
has_more: boolean;
/**
* The bot's active task in the session and what its job has done so
* far.
*/
task: BotTaskWithSteps | null;
};
/**
* What `api/admin/bots/conversation-history` answers: the history, or
* where to go instead when the user can't chat with the bot there any
* more.
*/
export type ConversationHistoryPayload = {
history?: ConversationHistory;
redirect?: string;
};
/**
* One build's `.bunext/public` files, downloaded by the service worker
* before the app runs (`grabPrecacheManifest`).
@@ -1107,15 +1133,6 @@ export type PagePropsType = {
crew_workspaces?: BUN_SQLITE_COBBLE_WORKSPACES[] | null;
bots?: BotWithLastMessage[] | null;
bot?: BotWithLastMessage | null;
bot_messages?: BotMessageWithAttachments[] | null;
/**
* The session has messages older than `bot_messages`.
*/
bot_messages_more?: boolean | null;
/**
* The open conversation's active session.
*/
bot_session?: BUN_SQLITE_COBBLE_BOT_SESSIONS | null;
/**
* The user's workspaces, for the rail.
*/
@@ -1158,10 +1175,6 @@ export type PagePropsType = {
* The open bot's computers, the one it's on first.
*/
bot_computers?: ComputerWithStatus[] | null;
/**
* The open bot's active task and what its job has done so far.
*/
bot_task?: BotTaskWithSteps | null;
/**
* The routines page: every schedule the user's bots have.
*/