Mark the session on screen as Current in the session lists

- The conversation page tells the rail and the sessions modal which
  session it shows (announceShownSession, useShownSession); they reload,
  so a session reopened by ?session_id= shows at once.
- Only that session is Current and greyed out (the active one without
  ?session_id=); the active row then leads back to it.
- The rail lists the session on screen even when it's older than its
  latest sessions.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-26 05:56:13 +01:00
co-authored by Claude Opus 5.5
parent cc109bc361
commit 657a76d868
10 changed files with 153 additions and 31 deletions
+1 -1
View File
@@ -88,7 +88,7 @@ Enables SQLite foreign keys (`enableForeignKeys`), `bunextInit()`, `ensureDocker
### Bots, sessions, chat ### Bots, sessions, chat
- **Tables:** `bots` (`kind`, `color` `#rrggbb`, `stone`, `role`, `system_prompt`; `hue` is a legacy fallback), `bot_user_faces`, `bot_sessions`, `bot_messages`, `bot_tasks`, `bot_task_steps` (unused yet). Enums in `src/dict/bot-dict.ts`. - **Tables:** `bots` (`kind`, `color` `#rrggbb`, `stone`, `role`, `system_prompt`; `hue` is a legacy fallback), `bot_user_faces`, `bot_sessions`, `bot_messages`, `bot_tasks`, `bot_task_steps` (unused yet). Enums in `src/dict/bot-dict.ts`.
- **Sessions:** one thread per user per bot in its own chat plus one per workspace; split into `bot_sessions` (rollover at the app setting `bot_session_max_messages`, default `AppData.BotSessionMaxMessages`, via `grabBotSessionMaxMessages`; titled and summarised by `name-bot-session.ts` from the session's opening (`grabSessionOpening`; `grabBotMessages` reads the latest), summary carried into the next session only when they're linked by `previous_session_id`, which only a carry-over sets; older implicit links were cleared on 2026-10-26). New session (`startBotSession`) and a conversation with no active session always start fresh. The rollover carries over like the bot does (`rollOverBotSession` -> `continueBotSession`, falling back to the stored summary; kept for the next message while a job is open in the full session or there's nothing to carry), inside send-message, so the user's message lands under the summary. Helpers in `src/functions/backend/db/bot-sessions/`. Job results land in the job's own session, even after it closes, except a reply that called `continue_in_new_session` (`carryOverBotSession`; the menu's and sessions modal's Continue in new session does the same through `continueBotSession`, the model writing title and summary, refused while a job is open in the session): its summary is kept on the closed session and opens the new one as a bot message (`carried_from_session_id`), the job's `session_id` moves so the answer follows, the new session is titled by the bot then (it has no first exchange to be named from), and the old one can never be reopened (`reopenBotSession` refuses; a thread showing it swaps its composer for a link to the new one). Session lists (rail, sessions modal) reopen a session on click, as before; a carried-over one can't be, so it opens to read (`?session_id=`, `conversationHref`). From half the session length the reply prompt suggests moving on (`longSessionNote`). Earlier sessions never enter the prompt (only the last one's summary): the thread can open them above a session (`useOlderMessages.loadPrevious`, a divider per session, quote and reply as usual), and replies get `search_past_sessions`/`read_past_session` (scoped to that user, bot and place, never the current session) when the conversation has any. - **Sessions:** one thread per user per bot in its own chat plus one per workspace; split into `bot_sessions` (rollover at the app setting `bot_session_max_messages`, default `AppData.BotSessionMaxMessages`, via `grabBotSessionMaxMessages`; titled and summarised by `name-bot-session.ts` from the session's opening (`grabSessionOpening`; `grabBotMessages` reads the latest), summary carried into the next session only when they're linked by `previous_session_id`, which only a carry-over sets; older implicit links were cleared on 2026-10-26). New session (`startBotSession`) and a conversation with no active session always start fresh. The rollover carries over like the bot does (`rollOverBotSession` -> `continueBotSession`, falling back to the stored summary; kept for the next message while a job is open in the full session or there's nothing to carry), inside send-message, so the user's message lands under the summary. Helpers in `src/functions/backend/db/bot-sessions/`. Job results land in the job's own session, even after it closes, except a reply that called `continue_in_new_session` (`carryOverBotSession`; the menu's and sessions modal's Continue in new session does the same through `continueBotSession`, the model writing title and summary, refused while a job is open in the session): its summary is kept on the closed session and opens the new one as a bot message (`carried_from_session_id`), the job's `session_id` moves so the answer follows, the new session is titled by the bot then (it has no first exchange to be named from), and the old one can never be reopened (`reopenBotSession` refuses; a thread showing it swaps its composer for a link to the new one). Session lists (rail, sessions modal) reopen a session on click, as before; a carried-over one can't be, so it opens to read (`?session_id=`, `conversationHref`). The page tells them which session it shows (`announceShownSession` -> `useShownSession`): they reload, mark it Current (greyed out; without `?session_id=` the active one is), list it even when it's older than the rail's latest, and the active row then leads back. From half the session length the reply prompt suggests moving on (`longSessionNote`). Earlier sessions never enter the prompt (only the last one's summary): the thread can open them above a session (`useOlderMessages.loadPrevious`, a divider per session, quote and reply as usual), and replies get `search_past_sessions`/`read_past_session` (scoped to that user, bot and place, never the current session) when the conversation has any.
- **Coordinator:** exactly one, the super admin's alone, created idempotently by `ensureCoordinatorBot`. Default prompt `src/data/coordinator-system-prompt.ts`, backfilled only when empty. - **Coordinator:** exactly one, the super admin's alone, created idempotently by `ensureCoordinatorBot`. Default prompt `src/data/coordinator-system-prompt.ts`, backfilled only when empty.
- **Routes:** `/admin/bots` reopens the last visited bot (cookie `cobble_last_visited_bot_id`, never a workspace conversation) if still reachable, else coordinator (super admin) / latest bot / first workspace / empty. `/admin/bots/[bot_id]` conversation (header, thread and composer at 760px, no right panel; `?session_id=` opens another of the user's sessions there: a closed one is reopened, except a carried-over one, which shows with `SessionMovedBar` instead of the composer, pointing where the conversation went on); `/admin/bots/[bot_id]/models`; `/admin/workspaces/[id]` dashboard; `/admin/workspaces/[id]/bots/[bot_id]` same conversation scoped to the workspace. - **Routes:** `/admin/bots` reopens the last visited bot (cookie `cobble_last_visited_bot_id`, never a workspace conversation) if still reachable, else coordinator (super admin) / latest bot / first workspace / empty. `/admin/bots/[bot_id]` conversation (header, thread and composer at 760px, no right panel; `?session_id=` opens another of the user's sessions there: a closed one is reopened, except a carried-over one, which shows with `SessionMovedBar` instead of the composer, pointing where the conversation went on); `/admin/bots/[bot_id]/models`; `/admin/workspaces/[id]` dashboard; `/admin/workspaces/[id]/bots/[bot_id]` same conversation scoped to the workspace.
- **Conversation loading:** pages render everything server-side (`grabConversationProps`) **except the history**, which the client loads (`api/admin/bots/conversation-history`, merged with what's already shown, refetched on bfcache restore) and pages older messages on scroll. Rail lists go through `railBots`/`railWorkspaces` (no prompts/memory, previews cut). While a reply is due the page polls `session-messages` (`use-reply-catch-up`) in case a push was missed. - **Conversation loading:** pages render everything server-side (`grabConversationProps`) **except the history**, which the client loads (`api/admin/bots/conversation-history`, merged with what's already shown, refetched on bfcache restore) and pages older messages on scroll. Rail lists go through `railBots`/`railWorkspaces` (no prompts/memory, previews cut). While a reply is due the page polls `session-messages` (`use-reply-catch-up`) in case a push was missed.
+21 -10
View File
@@ -12,31 +12,42 @@ type Props = {
loading?: boolean; loading?: boolean;
disabled?: boolean; disabled?: boolean;
onOpen: () => void; onOpen: () => void;
/**
* The session the page shows (`?session_id=`, else the active one):
* marked Current and greyed out.
*/
current: boolean;
}; };
/** /**
* One session in the sessions modal: its title, the summary or last * One session in the sessions modal: its title, the summary or last
* message, how long and how recent. The active one says so; any other * message, how long and how recent. The one on screen is
* reopens on click. * Current (greyed out); any other opens on click (reopening a closed one,
* going back to the active one).
*/ */
export default function BotSessionRow({ session, loading, disabled, onOpen }: Props) { export default function BotSessionRow({ session, loading, disabled, onOpen, current }: Props) {
const active = session.status == "active";
const preview = session.summary || session.last_message_text || ""; const preview = session.summary || session.last_message_text || "";
const at = session.last_activity_at || session.created_at; const at = session.last_activity_at || session.created_at;
return ( return (
<Button <Button
title={active ? "This is the current session" : `Reopen "${session.title || "Untitled"}"`} title={
current
? "This is the session on screen"
: session.status == "active"
? "Go back to the active session"
: `Reopen "${session.title || "Untitled"}"`
}
type="button" type="button"
variant="ghost" variant="ghost"
color="text" color="text"
loading={loading} loading={loading}
disabled={active || disabled} disabled={current || disabled}
onClick={onOpen} onClick={onOpen}
buttonContentProps={{ className: "w-full min-w-0 flex-nowrap" }} buttonContentProps={{ className: "w-full min-w-0 flex-nowrap" }}
className={twMerge( className={twMerge(
"w-full justify-start! items-start! gap-0! px-3! py-2.5! rounded-default text-left text-ink dark:text-ink hover:bg-ink/5 hover:dark:bg-ink/5 focus-visible:ring-2 focus-visible:ring-ink", "w-full justify-start! items-start! gap-0! px-3! py-2.5! rounded-default text-left text-ink dark:text-ink hover:bg-ink/5 hover:dark:bg-ink/5 focus-visible:ring-2 focus-visible:ring-ink",
active && "bg-sunken hover:bg-sunken hover:dark:bg-sunken opacity-100!", current && "bg-sunken hover:bg-sunken hover:dark:bg-sunken opacity-100!",
)} )}
> >
<Stack className="w-full min-w-0 gap-0.5! items-stretch"> <Stack className="w-full min-w-0 gap-0.5! items-stretch">
@@ -44,16 +55,16 @@ export default function BotSessionRow({ session, loading, disabled, onOpen }: Pr
<Span <Span
className={twMerge( className={twMerge(
"grow min-w-0 truncate text-sm font-bold leading-5", "grow min-w-0 truncate text-sm font-bold leading-5",
active ? "text-ink-secondary" : "text-ink", current ? "text-ink-secondary" : "text-ink",
)} )}
> >
{session.title || "Untitled session"} {session.title || "Untitled session"}
</Span> </Span>
{session.unread && !active ? ( {session.unread && !current ? (
<Span className="shrink-0 text-[11px] font-bold leading-4 text-ink">New</Span> <Span className="shrink-0 text-[11px] font-bold leading-4 text-ink">New</Span>
) : null} ) : null}
<Span className="shrink-0 text-[11px] leading-4 text-ink-secondary"> <Span className="shrink-0 text-[11px] leading-4 text-ink-secondary">
{active ? "Current" : at ? relativeTime({ at: Number(at) }) : ""} {current ? "Current" : at ? relativeTime({ at: Number(at) }) : ""}
</Span> </Span>
</Row> </Row>
<Span className="w-full truncate text-xs leading-4 text-ink-secondary"> <Span className="w-full truncate text-xs leading-4 text-ink-secondary">
+10 -3
View File
@@ -1,4 +1,6 @@
import ContinueSessionButton from "./continue-session-button"; import ContinueSessionButton from "./continue-session-button";
import useShownSession from "@/src/hooks/use-shown-session";
import conversationHref from "@/src/utils/conversation-href";
import { import {
useEffect, useEffect,
useState, useState,
@@ -52,10 +54,12 @@ export default function BotSessionsModal({
on_open, on_open,
}: Props) { }: Props) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const on_screen = useShownSession({ bot_id: Number(bot.id), workspace_id });
const sessions = useBotSessions({ const sessions = useBotSessions({
bot_id: Number(bot.id), bot_id: Number(bot.id),
workspace_id, workspace_id,
open, open,
refresh_key: on_screen ? `${on_screen.id}:${on_screen.status}` : null,
}); });
const name = bot.name || "the bot"; const name = bot.name || "the bot";
@@ -141,10 +145,13 @@ export default function BotSessionsModal({
session={session} session={session}
loading={sessions.busy_id == session.id} loading={sessions.busy_id == session.id}
disabled={Boolean(sessions.busy_id)} disabled={Boolean(sessions.busy_id)}
current={on_screen?.id ? on_screen.id == session.id : session.status == "active"}
onOpen={() => onOpen={() =>
sessions.reopenSession({ session.status == "active"
session_id: Number(session.id), ? window.location.assign(conversationHref({ bot_id: Number(bot.id), workspace_id }))
}) : sessions.reopenSession({
session_id: Number(session.id),
})
} }
/> />
))} ))}
@@ -0,0 +1,18 @@
import type { ShownSession } from "@/src/types";
export const ShownSessionEvent = "cobble:session-shown";
/**
* The last session a conversation page showed, for a rail list that
* mounts after the page told it.
*/
export let lastShownSession: ShownSession | null = null;
/**
* Tells the rail which session the conversation page shows, once its
* history loads (`useShownSession`).
*/
export default function announceShownSession(shown: ShownSession) {
lastShownSession = shown;
window.dispatchEvent(new CustomEvent(ShownSessionEvent, { detail: shown }));
}
+7 -2
View File
@@ -15,6 +15,11 @@ type Params = {
* How many to load, latest first; the modal leaves it out for a page. * How many to load, latest first; the modal leaves it out for a page.
*/ */
limit?: number; limit?: number;
/**
* Reloads the list whenever it changes (the session the page shows,
* which loading it may have reopened).
*/
refresh_key?: string | number | null;
}; };
type ChangedSession = { type ChangedSession = {
@@ -51,7 +56,7 @@ function belongsToConversation({
* session started in the conversation (or named once its first reply is * session started in the conversation (or named once its first reply is
* in) shows without a reload. * in) shows without a reload.
*/ */
export default function useBotSessions({ bot_id, workspace_id, open, limit }: Params) { export default function useBotSessions({ bot_id, workspace_id, open, limit, refresh_key }: Params) {
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [sessions, setSessions] = useState<BotSessionListItem[]>([]); const [sessions, setSessions] = useState<BotSessionListItem[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -111,7 +116,7 @@ export default function useBotSessions({ bot_id, workspace_id, open, limit }: Pa
stale = true; stale = true;
clearTimeout(timer); clearTimeout(timer);
}; };
}, [open, query, bot_id, workspace_id, limit, revision]); }, [open, query, bot_id, workspace_id, limit, revision, refresh_key]);
const act = async ({ id, path, body }: { id: number | "new"; path: "new-session" | "reopen-session"; body: Record<string, unknown> }) => { const act = async ({ id, path, body }: { id: number | "new"; path: "new-session" | "reopen-session"; body: Record<string, unknown> }) => {
if (busy_id) return; if (busy_id) return;
+38
View File
@@ -0,0 +1,38 @@
import { useEffect, useState } from "react";
import { lastShownSession, ShownSessionEvent } from "@/src/functions/frontend/announce-shown-session";
import type { ShownSession } from "@/src/types";
type Params = {
bot_id: number;
workspace_id?: number | null;
};
function isFor({ shown, bot_id, workspace_id }: Params & { shown: ShownSession | null }) {
return Boolean(
shown && shown.bot_id == bot_id && (shown.workspace_id || null) == (workspace_id || null),
);
}
/**
* The session the conversation page shows, when it's this conversation's
* (`announceShownSession`): a session opened by `?session_id=`, a
* reopened one or the active one.
*/
export default function useShownSession({ bot_id, workspace_id }: Params) {
const [shown, setShown] = useState<ShownSession | null>(() =>
isFor({ shown: lastShownSession, bot_id, workspace_id }) ? lastShownSession : null,
);
useEffect(() => {
const listener = (e: Event) => {
const detail = (e as CustomEvent<ShownSession>).detail;
if (isFor({ shown: detail, bot_id, workspace_id })) setShown(detail);
};
window.addEventListener(ShownSessionEvent, listener);
return () => window.removeEventListener(ShownSessionEvent, listener);
}, [bot_id, workspace_id]);
return shown?.session || null;
}
@@ -9,40 +9,48 @@ type Props = {
loading?: boolean; loading?: boolean;
disabled?: boolean; disabled?: boolean;
onOpen: () => void; onOpen: () => void;
/**
* The session the page shows (`?session_id=`, else the active one):
* marked Current and greyed out.
*/
current: boolean;
}; };
/** /**
* One session in the rail's sub-list under the open bot: aligned with the * One session in the rail's sub-list under the open bot: aligned with the
* bot's name, its title and how recent. The active one says so; any other * bot's name, its title and how recent. The one on screen is
* reopens on click. * Current (greyed out); any other opens on click (reopening a closed one,
* going back to the active one).
*/ */
export default function RailBotSessionRow({ export default function RailBotSessionRow({
session, session,
loading, loading,
disabled, disabled,
onOpen, onOpen,
current,
}: Props) { }: Props) {
const active = session.status == "active";
const at = session.last_activity_at || session.created_at; const at = session.last_activity_at || session.created_at;
return ( return (
<Button <Button
title={ title={
active current
? "This is the current session" ? "This is the session on screen"
: `Reopen "${session.title || "Untitled"}"` : session.status == "active"
? "Go back to the active session"
: `Reopen "${session.title || "Untitled"}"`
} }
type="button" type="button"
variant="ghost" variant="ghost"
color="text" color="text"
loading={loading} loading={loading}
disabled={active || disabled} disabled={current || disabled}
onClick={onOpen} onClick={onOpen}
buttonContentProps={{ className: "w-full min-w-0 flex-nowrap" }} buttonContentProps={{ className: "w-full min-w-0 flex-nowrap" }}
className={twMerge( className={twMerge(
"w-full h-8 shrink-0 justify-start! items-center! gap-2! rounded-default px-2! @rail:pl-14! text-left text-ink dark:text-ink hover:bg-ink/5 hover:dark:bg-ink/5 focus-visible:ring-2 focus-visible:ring-ink", "w-full h-8 shrink-0 justify-start! items-center! gap-2! rounded-default px-2! @rail:pl-14! text-left text-ink dark:text-ink hover:bg-ink/5 hover:dark:bg-ink/5 focus-visible:ring-2 focus-visible:ring-ink",
active && current &&
"bg-transparent hover:bg-transparent hover:dark:bg-transparent opacity-100!", "bg-transparent hover:bg-transparent hover:dark:bg-transparent opacity-100!",
)} )}
loadingProps={{ loadingProps={{
size: "smaller", size: "smaller",
@@ -51,18 +59,18 @@ export default function RailBotSessionRow({
<Span <Span
className={twMerge( className={twMerge(
"grow min-w-0 truncate text-xs leading-4 font-bold", "grow min-w-0 truncate text-xs leading-4 font-bold",
active ? "text-ink-secondary" : "text-ink", current ? "text-ink-secondary" : "text-ink",
)} )}
> >
{session.title || "Untitled session"} {session.title || "Untitled session"}
</Span> </Span>
{session.unread && !active ? ( {session.unread && !current ? (
<Span className="shrink-0 text-[11px] font-bold leading-4 text-ink"> <Span className="shrink-0 text-[11px] font-bold leading-4 text-ink">
New New
</Span> </Span>
) : null} ) : null}
<Span className="shrink-0 text-[11px] leading-4 text-ink-secondary"> <Span className="shrink-0 text-[11px] leading-4 text-ink-secondary">
{active {current
? "Current" ? "Current"
: at : at
? relativeTime({ at: Number(at) }) ? relativeTime({ at: Number(at) })
@@ -4,6 +4,8 @@ import Stack from "@moduletrace/twui/layout/Stack";
import BotSessionsModal from "@/src/components/general/bot-sessions-modal"; import BotSessionsModal from "@/src/components/general/bot-sessions-modal";
import { AppData } from "@/src/data/app-data"; import { AppData } from "@/src/data/app-data";
import useBotSessions from "@/src/hooks/use-bot-sessions"; import useBotSessions from "@/src/hooks/use-bot-sessions";
import useShownSession from "@/src/hooks/use-shown-session";
import conversationHref from "@/src/utils/conversation-href";
import type { BotWithLastMessage } from "@/src/types"; import type { BotWithLastMessage } from "@/src/types";
import RailBotSessionRow from "./rail-bot-session-row"; import RailBotSessionRow from "./rail-bot-session-row";
@@ -13,18 +15,30 @@ type Props = {
}; };
/** /**
* Under the open bot in the rail: its latest sessions, latest first, and, * Under the open bot in the rail: its latest sessions, latest first (plus
* the one on screen when it's older). The one on screen (`?session_id=`,
* else the active one) is Current. Then,
* once there are more than the ones listed, "All sessions" which opens the * once there are more than the ones listed, "All sessions" which opens the
* conversation's sessions modal. Hidden when the rail collapses to icons. * conversation's sessions modal. Hidden when the rail collapses to icons.
*/ */
export default function RailBotSessions({ bot, workspace_id }: Props) { export default function RailBotSessions({ bot, workspace_id }: Props) {
const on_screen = useShownSession({ bot_id: Number(bot.id), workspace_id });
const sessions = useBotSessions({ const sessions = useBotSessions({
bot_id: Number(bot.id), bot_id: Number(bot.id),
workspace_id, workspace_id,
open: true, open: true,
limit: AppData.RailRecentSessions + 1, limit: AppData.RailRecentSessions + 1,
refresh_key: on_screen ? `${on_screen.id}:${on_screen.status}` : null,
}); });
const shown = sessions.sessions.slice(0, AppData.RailRecentSessions); const recent = sessions.sessions.slice(0, AppData.RailRecentSessions);
/**
* The session on screen is always listed, even when it's older than
* the latest ones.
*/
const shown =
on_screen?.id && !recent.some((session) => session.id == on_screen.id)
? [...recent, { ...on_screen, message_count: 0 }]
: recent;
if (!shown.length) return null; if (!shown.length) return null;
@@ -41,7 +55,12 @@ export default function RailBotSessions({ bot, workspace_id }: Props) {
session={session} session={session}
loading={sessions.busy_id == session.id} loading={sessions.busy_id == session.id}
disabled={Boolean(sessions.busy_id)} disabled={Boolean(sessions.busy_id)}
onOpen={() => sessions.reopenSession({ session_id: Number(session.id) })} current={on_screen?.id ? on_screen.id == session.id : session.status == "active"}
onOpen={() =>
session.status == "active"
? window.location.assign(conversationHref({ bot_id: Number(bot.id), workspace_id }))
: sessions.reopenSession({ session_id: Number(session.id) })
}
/> />
))} ))}
</Stack> </Stack>
@@ -40,6 +40,7 @@ import mergeLoadedHistory from "../(utils)/merge-loaded-history";
import LoadingLine from "@/src/components/general/loading-line"; import LoadingLine from "@/src/components/general/loading-line";
import SessionMovedBar from "../(partials)/session-moved-bar"; import SessionMovedBar from "../(partials)/session-moved-bar";
import syncSessionQuery from "../(utils)/sync-session-query"; import syncSessionQuery from "../(utils)/sync-session-query";
import announceShownSession from "@/src/functions/frontend/announce-shown-session";
/** /**
* The conversation column: header, bottom-anchored thread, composer, the * The conversation column: header, bottom-anchored thread, composer, the
@@ -172,6 +173,11 @@ export default function Conversation() {
setSessionId(loaded_session_id); setSessionId(loaded_session_id);
setMovedToSessionId(history.moved_to_session_id || null); setMovedToSessionId(history.moved_to_session_id || null);
syncSessionQuery({ session: history.session }); syncSessionQuery({ session: history.session });
announceShownSession({
bot_id: Number(bot?.id) || 0,
workspace_id,
session: history.session,
});
setMessages((prev) => mergeLoadedHistory({ shown: prev, history })); setMessages((prev) => mergeLoadedHistory({ shown: prev, history }));
older.reset({ older.reset({
session_id: loaded_session_id, session_id: loaded_session_id,
+10
View File
@@ -1581,6 +1581,16 @@ export type ConversationExportSession = {
created_at: number; created_at: number;
}; };
/**
* The session a conversation page shows (`cobble:session-shown`), told to
* the rail so it marks it.
*/
export type ShownSession = {
bot_id: number;
workspace_id: number | null;
session: BUN_SQLITE_COBBLE_BOT_SESSIONS;
};
export type ConversationHistoryPayload = { export type ConversationHistoryPayload = {
history?: ConversationHistory; history?: ConversationHistory;
redirect?: string; redirect?: string;