List the open bot's recent sessions in the rail

Show the latest five sessions under the active bot, with All sessions opening the existing modal once there are more. Share the session modal, row and hook outside the conversation page, and mark the current session with a muted row.
This commit is contained in:
2026-10-19 17:27:57 +01:00
parent dbfe48796b
commit 53cf054d70
18 changed files with 236 additions and 39 deletions
@@ -1,20 +0,0 @@
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
import type { APIResponseObject } from "@moduletrace/bunext/types";
type Params = {
/**
* Under `/api/admin/bots/`.
*/
path: "list-sessions" | "new-session" | "reopen-session" | "session-messages";
body: Record<string, unknown>;
};
/**
* Posts to a conversation-session API route.
*/
export default async function postSessionApi<T extends { [k: string]: any }>({ path, body }: Params) {
return await fetchApi<Record<string, unknown>, APIResponseObject<T>>(`/api/admin/bots/${path}`, {
method: "POST",
body,
});
}
@@ -1,83 +0,0 @@
import { useEffect, useState } from "react";
import type { BotSessionListItem } from "@/src/types";
import postSessionApi from "../(functions)/post-session-api";
type Params = {
bot_id: number;
workspace_id?: number | null;
/**
* Loads only while the modal is open.
*/
open: boolean;
};
/**
* A conversation's sessions, latest first, searched as the user types
* (debounced). Starting or reopening a session reloads the page onto it.
*/
export default function useBotSessions({ bot_id, workspace_id, open }: Params) {
const [query, setQuery] = useState("");
const [sessions, setSessions] = useState<BotSessionListItem[]>([]);
const [loading, setLoading] = useState(false);
const [busy_id, setBusyId] = useState<number | "new">();
const [error, setError] = useState<string>();
useEffect(() => {
if (!open) return;
let stale = false;
const timer = setTimeout(async () => {
setLoading(true);
const res = await postSessionApi<BotSessionListItem>({
path: "list-sessions",
body: { bot_id, workspace_id, query: query.trim() || undefined },
}).catch(() => null);
if (stale) return;
setLoading(false);
if (res?.success) {
setSessions(res.payload || []);
setError(undefined);
} else {
setError(res?.msg || "Couldn't load the sessions.");
}
}, query ? 250 : 0);
return () => {
stale = true;
clearTimeout(timer);
};
}, [open, query, bot_id, workspace_id]);
const act = async ({ id, path, body }: { id: number | "new"; path: "new-session" | "reopen-session"; body: Record<string, unknown> }) => {
if (busy_id) return;
setBusyId(id);
setError(undefined);
const res = await postSessionApi({ path, body }).catch(() => null);
if (res?.success) {
window.location.reload();
return;
}
setBusyId(undefined);
setError(res?.msg || "That didn't work.");
};
return {
query,
setQuery,
sessions,
loading,
busy_id,
error,
startSession: () => act({ id: "new", path: "new-session", body: { bot_id, workspace_id } }),
reopenSession: ({ session_id }: { session_id: number }) =>
act({ id: session_id, path: "reopen-session", body: { session_id } }),
};
}
@@ -1,7 +1,7 @@
import { useEffect, useRef } from "react";
import { AppData } from "@/src/data/app-data";
import type { BotMessageWithAttachments, WebSocketDataType } from "@/src/types";
import postSessionApi from "../(functions)/post-session-api";
import postSessionApi from "@/src/functions/frontend/post-session-api";
type Params = {
session_id?: number | null;
@@ -1,60 +0,0 @@
import Button from "@moduletrace/twui/layout/Button";
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import { twMerge } from "tailwind-merge";
import type { BotSessionListItem } from "@/src/types";
import messagePreview from "@/src/utils/message-preview";
import relativeTime from "@/src/utils/relative-time";
type Props = {
session: BotSessionListItem;
loading?: boolean;
disabled?: boolean;
onOpen: () => void;
};
/**
* 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
* reopens on click.
*/
export default function BotSessionRow({ session, loading, disabled, onOpen }: Props) {
const active = session.status == "active";
const preview = session.summary || session.last_message_text || "";
const at = session.last_activity_at || session.created_at;
return (
<Button
title={active ? "This is the current session" : `Reopen "${session.title || "Untitled"}"`}
type="button"
variant="ghost"
color="text"
loading={loading}
disabled={active || disabled}
onClick={onOpen}
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-sunken hover:dark:bg-sunken focus-visible:ring-2 focus-visible:ring-ink",
active && "bg-ink/5 opacity-100!",
)}
>
<Stack className="w-full min-w-0 gap-0.5! items-stretch">
<Row className="w-full gap-2! flex-nowrap items-center">
<Span className="grow min-w-0 truncate text-sm font-bold leading-5 text-ink">
{session.title || "Untitled session"}
</Span>
{session.unread && !active ? (
<Span className="shrink-0 text-[11px] font-bold leading-4 text-ink">New</Span>
) : null}
<Span className="shrink-0 text-[11px] leading-4 text-ink-secondary">
{active ? "Current" : at ? relativeTime({ at: Number(at) }) : ""}
</Span>
</Row>
<Span className="w-full truncate text-xs leading-4 text-ink-secondary">
{`${session.message_count} message${session.message_count == 1 ? "" : "s"}`}
{preview ? ` · ${messagePreview({ text: preview })}` : ""}
</Span>
</Stack>
</Button>
);
}
@@ -1,96 +0,0 @@
import { useState } from "react";
import Input from "@moduletrace/twui/form/Input";
import Modal from "@moduletrace/twui/elements/Modal";
import Button from "@moduletrace/twui/layout/Button";
import H2 from "@moduletrace/twui/layout/H2";
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import { History, Plus } from "lucide-react";
import type { BotWithLastMessage } from "@/src/types";
import SettingsStatus from "@/src/pages/admin/settings/(partials)/settings-status";
import useBotSessions from "../(hooks)/use-bot-sessions";
import BotSessionRow from "./bot-session-row";
import HeaderIconButton from "@/src/components/general/header-icon-button";
type Props = {
bot: BotWithLastMessage;
workspace_id?: number | null;
/**
* A bot message arrived in another session of this conversation.
*/
has_other_unread?: boolean;
};
/**
* The header's sessions icon. A conversation is split into sessions so a
* bot's context never grows without bound; this lists them, searches
* them, starts a fresh one, or reopens an old one to carry on.
*/
export default function BotSessionsModal({ bot, workspace_id, has_other_unread }: Props) {
const [open, setOpen] = useState(false);
const sessions = useBotSessions({ bot_id: Number(bot.id), workspace_id, open });
const name = bot.name || "the bot";
return (
<Modal
target={
<HeaderIconButton
icon={History}
title={has_other_unread ? "Sessions: new messages in another session" : "Sessions"}
/>
}
targetWrapperProps={{ className: "shrink-0" }}
open={open}
setOpen={setOpen}
className="max-w-lg bg-paper p-6"
>
<Row className="w-full gap-3! flex-nowrap items-start justify-between">
<Stack className="min-w-0 gap-1! items-stretch">
<H2 className="text-lg! leading-6! tracking-normal! font-bold text-ink">Sessions</H2>
<Span className="text-[13px] leading-[18px] text-ink-secondary">
{`Each session keeps ${name}'s context short. A new one starts with a summary of the last; reopen any to pick up where you left off.`}
</Span>
</Stack>
<Button
title="Start a new session"
type="button"
size="small"
loading={sessions.busy_id == "new"}
disabled={Boolean(sessions.busy_id)}
onClick={sessions.startSession}
className="shrink-0 gap-1!"
>
<Plus size={14} aria-hidden />
New
</Button>
</Row>
<Input
id="bot-sessions-search"
title="Search sessions"
placeholder="Search titles, summaries and messages"
autoComplete="off"
defaultValue={sessions.query}
onChange={(e) => sessions.setQuery(e.target.value)}
wrapperWrapperProps={{ className: "mt-4" }}
/>
<SettingsStatus status={sessions.error ? { error: true, msg: sessions.error } : undefined} />
<Stack className="w-full mt-2 max-h-[60vh] overflow-y-auto gap-0! items-stretch">
{sessions.sessions.map((session) => (
<BotSessionRow
key={session.id}
session={session}
loading={sessions.busy_id == session.id}
disabled={Boolean(sessions.busy_id)}
onOpen={() => sessions.reopenSession({ session_id: Number(session.id) })}
/>
))}
{sessions.sessions.length || sessions.loading ? null : (
<Span className="px-3 py-6 text-[13px] leading-[18px] text-ink-secondary">
{sessions.query.trim() ? `No session matches "${sessions.query.trim()}".` : "No sessions yet."}
</Span>
)}
</Stack>
</Modal>
);
}
@@ -12,7 +12,7 @@ import { BotComputersContext } from "../(data)/bot-computers-context";
import BotComputersModal from "./bot-computers-modal";
import BotModelsModal from "./bot-models-modal";
import BotProfileModal from "./bot-profile-modal";
import BotSessionsModal from "./bot-sessions-modal";
import BotSessionsModal from "@/src/components/general/bot-sessions-modal";
import NewSessionButton from "./new-session-button";
import HeaderIconButton from "@/src/components/general/header-icon-button";
import RailDrawerButton from "@/src/layouts/bots/(partials)/rail-drawer-button";
@@ -1,6 +1,6 @@
import { useState } from "react";
import { MessageSquarePlus } from "lucide-react";
import postSessionApi from "../(functions)/post-session-api";
import postSessionApi from "@/src/functions/frontend/post-session-api";
import HeaderIconButton from "@/src/components/general/header-icon-button";
type Props = {