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:
@@ -0,0 +1,66 @@
|
||||
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}
|
||||
buttonContentProps={{ className: "w-full min-w-0 flex-nowrap" }}
|
||||
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",
|
||||
active && "bg-sunken hover:bg-sunken hover:dark:bg-sunken 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={twMerge(
|
||||
"grow min-w-0 truncate text-sm font-bold leading-5",
|
||||
active ? "text-ink-secondary" : "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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { useState, type ComponentProps, type ReactNode } 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, CircleAlert } from "lucide-react";
|
||||
import type { BotWithLastMessage } from "@/src/types";
|
||||
import useBotSessions from "@/src/hooks/use-bot-sessions";
|
||||
import BotSessionRow from "./bot-session-row";
|
||||
import HeaderIconButton from "./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;
|
||||
/**
|
||||
* What opens it; the conversation header's sessions icon by default.
|
||||
*/
|
||||
target?: ReactNode;
|
||||
target_wrapper_props?: ComponentProps<typeof Modal>["targetWrapperProps"];
|
||||
};
|
||||
|
||||
/**
|
||||
* The sessions icon (or another target) that opens a conversation's
|
||||
* sessions. 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,
|
||||
target,
|
||||
target_wrapper_props,
|
||||
}: 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={
|
||||
target ?? (
|
||||
<HeaderIconButton
|
||||
icon={History}
|
||||
title={has_other_unread ? "Sessions: new messages in another session" : "Sessions"}
|
||||
/>
|
||||
)
|
||||
}
|
||||
targetWrapperProps={target_wrapper_props ?? { className: target ? "w-full" : "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" }}
|
||||
/>
|
||||
{sessions.error ? (
|
||||
<Span role="alert" className="mt-2 flex items-center gap-1.5 text-[13px] leading-[18px] text-ink">
|
||||
<CircleAlert size={14} aria-hidden className="shrink-0" />
|
||||
{sessions.error}
|
||||
</Span>
|
||||
) : null}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user