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:
+9
-3
@@ -33,14 +33,20 @@ export default function BotSessionRow({ session, loading, disabled, onOpen }: Pr
|
||||
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-sunken hover:dark:bg-sunken focus-visible:ring-2 focus-visible:ring-ink",
|
||||
active && "bg-ink/5 opacity-100!",
|
||||
"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="grow min-w-0 truncate text-sm font-bold leading-5 text-ink">
|
||||
<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 ? (
|
||||
+33
-15
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
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";
|
||||
@@ -6,12 +6,11 @@ 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 { History, Plus, CircleAlert } 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 useBotSessions from "@/src/hooks/use-bot-sessions";
|
||||
import BotSessionRow from "./bot-session-row";
|
||||
import HeaderIconButton from "@/src/components/general/header-icon-button";
|
||||
import HeaderIconButton from "./header-icon-button";
|
||||
|
||||
type Props = {
|
||||
bot: BotWithLastMessage;
|
||||
@@ -20,14 +19,26 @@ type Props = {
|
||||
* 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 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.
|
||||
* 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 }: Props) {
|
||||
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";
|
||||
@@ -35,12 +46,14 @@ export default function BotSessionsModal({ bot, workspace_id, has_other_unread }
|
||||
return (
|
||||
<Modal
|
||||
target={
|
||||
<HeaderIconButton
|
||||
icon={History}
|
||||
title={has_other_unread ? "Sessions: new messages in another session" : "Sessions"}
|
||||
/>
|
||||
target ?? (
|
||||
<HeaderIconButton
|
||||
icon={History}
|
||||
title={has_other_unread ? "Sessions: new messages in another session" : "Sessions"}
|
||||
/>
|
||||
)
|
||||
}
|
||||
targetWrapperProps={{ className: "shrink-0" }}
|
||||
targetWrapperProps={target_wrapper_props ?? { className: target ? "w-full" : "shrink-0" }}
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
className="max-w-lg bg-paper p-6"
|
||||
@@ -74,7 +87,12 @@ export default function BotSessionsModal({ bot, workspace_id, has_other_unread }
|
||||
onChange={(e) => sessions.setQuery(e.target.value)}
|
||||
wrapperWrapperProps={{ className: "mt-4" }}
|
||||
/>
|
||||
<SettingsStatus status={sessions.error ? { error: true, msg: sessions.error } : undefined} />
|
||||
{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
|
||||
@@ -251,6 +251,11 @@ export const AppData = {
|
||||
* Stones shown beside "See all bots".
|
||||
*/
|
||||
RailSeeAllStones: 4,
|
||||
/**
|
||||
* Recent sessions the rail lists under the open bot, before "All
|
||||
* sessions".
|
||||
*/
|
||||
RailRecentSessions: 5,
|
||||
/**
|
||||
* Recent sessions a workspace's dashboard shows.
|
||||
*/
|
||||
|
||||
@@ -14,6 +14,10 @@ type Params = {
|
||||
*/
|
||||
query?: string;
|
||||
page?: number;
|
||||
/**
|
||||
* How many to return; a page by default.
|
||||
*/
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -27,10 +31,11 @@ export default async function grabBotSessions({
|
||||
workspace_id,
|
||||
query,
|
||||
page = 1,
|
||||
limit,
|
||||
}: Params): Promise<BotSessionListItem[]> {
|
||||
const search = query?.trim();
|
||||
const like = search ? `%${search.replace(/[\\%_]/g, (c) => `\\${c}`)}%` : null;
|
||||
const limit = AppData.BotSessionsPageSize;
|
||||
const page_size = limit || AppData.BotSessionsPageSize;
|
||||
|
||||
let sql = ``;
|
||||
|
||||
@@ -56,8 +61,8 @@ export default async function grabBotSessions({
|
||||
bot_id,
|
||||
workspace_id ? Number(workspace_id) : null,
|
||||
...(like ? [like, like, like] : []),
|
||||
limit,
|
||||
(Math.max(1, page) - 1) * limit,
|
||||
page_size,
|
||||
(Math.max(1, page) - 1) * page_size,
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
+9
-5
@@ -1,21 +1,25 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { BotSessionListItem } from "@/src/types";
|
||||
import postSessionApi from "../(functions)/post-session-api";
|
||||
import postSessionApi from "@/src/functions/frontend/post-session-api";
|
||||
|
||||
type Params = {
|
||||
bot_id: number;
|
||||
workspace_id?: number | null;
|
||||
/**
|
||||
* Loads only while the modal is open.
|
||||
* Loads only while the caller needs the sessions.
|
||||
*/
|
||||
open: boolean;
|
||||
/**
|
||||
* How many to load, latest first; the modal leaves it out for a page.
|
||||
*/
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
export default function useBotSessions({ bot_id, workspace_id, open, limit }: Params) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [sessions, setSessions] = useState<BotSessionListItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -31,7 +35,7 @@ export default function useBotSessions({ bot_id, workspace_id, open }: Params) {
|
||||
|
||||
const res = await postSessionApi<BotSessionListItem>({
|
||||
path: "list-sessions",
|
||||
body: { bot_id, workspace_id, query: query.trim() || undefined },
|
||||
body: { bot_id, workspace_id, query: query.trim() || undefined, limit },
|
||||
}).catch(() => null);
|
||||
|
||||
if (stale) return;
|
||||
@@ -50,7 +54,7 @@ export default function useBotSessions({ bot_id, workspace_id, open }: Params) {
|
||||
stale = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [open, query, bot_id, workspace_id]);
|
||||
}, [open, query, bot_id, workspace_id, limit]);
|
||||
|
||||
const act = async ({ id, path, body }: { id: number | "new"; path: "new-session" | "reopen-session"; body: Record<string, unknown> }) => {
|
||||
if (busy_id) return;
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { BotWithLastMessage } from "@/src/types";
|
||||
import BotRow from "./bot-row";
|
||||
import RailBotSessions from "./rail-bot-sessions";
|
||||
|
||||
type Props = {
|
||||
bot: BotWithLastMessage;
|
||||
href: string;
|
||||
selected?: boolean;
|
||||
workspace_id?: number | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* A worker in the rail, and its sessions while it is the open conversation.
|
||||
*/
|
||||
export default function RailBotEntry({ bot, href, selected, workspace_id }: Props) {
|
||||
return (
|
||||
<>
|
||||
<BotRow bot={bot} href={href} selected={selected} />
|
||||
{selected ? <RailBotSessions key={bot.id} bot={bot} workspace_id={workspace_id} /> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import type { BotSessionListItem } from "@/src/types";
|
||||
import relativeTime from "@/src/utils/relative-time";
|
||||
|
||||
type Props = {
|
||||
session: BotSessionListItem;
|
||||
loading?: boolean;
|
||||
disabled?: boolean;
|
||||
onOpen: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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
|
||||
* reopens on click.
|
||||
*/
|
||||
export default function RailBotSessionRow({ session, loading, disabled, onOpen }: Props) {
|
||||
const active = session.status == "active";
|
||||
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 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 && "bg-sunken hover:bg-sunken hover:dark:bg-sunken opacity-100!",
|
||||
)}
|
||||
>
|
||||
<Span
|
||||
className={twMerge(
|
||||
"grow min-w-0 truncate text-xs leading-4 font-bold",
|
||||
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>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import BotSessionsModal from "@/src/components/general/bot-sessions-modal";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import useBotSessions from "@/src/hooks/use-bot-sessions";
|
||||
import type { BotWithLastMessage } from "@/src/types";
|
||||
import RailBotSessionRow from "./rail-bot-session-row";
|
||||
|
||||
type Props = {
|
||||
bot: BotWithLastMessage;
|
||||
workspace_id?: number | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Under the open bot in the rail: its latest sessions, latest first, and,
|
||||
* once there are more than the ones listed, "All sessions" which opens the
|
||||
* conversation's sessions modal. Hidden when the rail collapses to icons.
|
||||
*/
|
||||
export default function RailBotSessions({ bot, workspace_id }: Props) {
|
||||
const sessions = useBotSessions({
|
||||
bot_id: Number(bot.id),
|
||||
workspace_id,
|
||||
open: true,
|
||||
limit: AppData.RailRecentSessions + 1,
|
||||
});
|
||||
const shown = sessions.sessions.slice(0, AppData.RailRecentSessions);
|
||||
|
||||
if (!shown.length) return null;
|
||||
|
||||
return (
|
||||
<Stack className="hidden @rail:flex w-full shrink-0 flex-col gap-0! items-stretch pb-1">
|
||||
{shown.map((session) => (
|
||||
<RailBotSessionRow
|
||||
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 > AppData.RailRecentSessions ? (
|
||||
<BotSessionsModal
|
||||
bot={bot}
|
||||
workspace_id={workspace_id}
|
||||
target={
|
||||
<Button
|
||||
title="Open all sessions"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="text"
|
||||
className="w-full h-8 shrink-0 justify-start! items-center! gap-2! rounded-default px-2! @rail:pl-14! text-left text-ink-secondary dark:text-ink-secondary hover:text-ink hover:dark:text-ink hover:bg-sunken hover:dark:bg-sunken transition-colors duration-[var(--m-fast)] focus-visible:ring-2 focus-visible:ring-ink"
|
||||
>
|
||||
<Span className="text-[11px] font-bold leading-4">All sessions</Span>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { AppData } from "@/src/data/app-data";
|
||||
import type { BotWithLastMessage } from "@/src/types";
|
||||
import pickRailBots from "../(utils)/pick-rail-bots";
|
||||
import AllBotsModal from "./all-bots-modal";
|
||||
import BotRow from "./bot-row";
|
||||
import RailBotEntry from "./rail-bot-entry";
|
||||
import RailSectionLabel from "./rail-section-label";
|
||||
import RailSeeAllRow from "./rail-see-all-row";
|
||||
|
||||
@@ -15,6 +15,10 @@ type Props = {
|
||||
*/
|
||||
workers: BotWithLastMessage[];
|
||||
selected_id?: number;
|
||||
/**
|
||||
* Set inside a workspace, whose conversations have their own sessions.
|
||||
*/
|
||||
workspace_id?: number | null;
|
||||
hrefFor: (bot: BotWithLastMessage) => string;
|
||||
/**
|
||||
* Shown when there are no bots yet.
|
||||
@@ -28,7 +32,7 @@ type Props = {
|
||||
* active (and any that want attention), then "See all bots" for the rest,
|
||||
* which lists them all in a modal.
|
||||
*/
|
||||
export default function RailBotsSection({ title, workers, selected_id, hrefFor, empty, action }: Props) {
|
||||
export default function RailBotsSection({ title, workers, selected_id, workspace_id, hrefFor, empty, action }: Props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const { shown, hidden } = pickRailBots({ workers, selected_id, limit: AppData.RailRecentBots });
|
||||
|
||||
@@ -36,7 +40,13 @@ export default function RailBotsSection({ title, workers, selected_id, hrefFor,
|
||||
<>
|
||||
<RailSectionLabel title={title} action={action} />
|
||||
{shown.map((bot) => (
|
||||
<BotRow key={bot.id} bot={bot} href={hrefFor(bot)} selected={bot.id == selected_id} />
|
||||
<RailBotEntry
|
||||
key={bot.id}
|
||||
bot={bot}
|
||||
href={hrefFor(bot)}
|
||||
selected={bot.id == selected_id}
|
||||
workspace_id={workspace_id}
|
||||
/>
|
||||
))}
|
||||
{workers.length ? null : (
|
||||
<Span className="hidden @rail:block px-3 py-2 text-[13px] leading-[18px] text-ink-secondary">
|
||||
|
||||
@@ -4,6 +4,7 @@ import { AppContext } from "@/src/pages/__root";
|
||||
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
|
||||
import type { BotWithLastMessage, WorkspaceWithActivity } from "@/src/types";
|
||||
import CoordinatorRow from "./coordinator-row";
|
||||
import RailBotSessions from "./rail-bot-sessions";
|
||||
import RailBotsSection from "./rail-bots-section";
|
||||
import RailSectionAddButton from "./rail-section-add-button";
|
||||
import RailWorkspacesSection from "./rail-workspaces-section";
|
||||
@@ -27,11 +28,16 @@ export default function RailCrewList({ bots, workspaces, selected_id }: Props) {
|
||||
return (
|
||||
<>
|
||||
{coordinator ? (
|
||||
<CoordinatorRow
|
||||
bot={coordinator}
|
||||
bots={bots}
|
||||
selected={coordinator.id === selected_id}
|
||||
/>
|
||||
<>
|
||||
<CoordinatorRow
|
||||
bot={coordinator}
|
||||
bots={bots}
|
||||
selected={coordinator.id === selected_id}
|
||||
/>
|
||||
{coordinator.id === selected_id ? (
|
||||
<RailBotSessions key={coordinator.id} bot={coordinator} />
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
<RailBotsSection
|
||||
action={<RailSectionAddButton title="Add a bot" onClick={() => openForm({ kind: "bot" })} />}
|
||||
|
||||
@@ -44,6 +44,7 @@ export default function RailWorkspaceList({ workspace, bots, selected_id, on_wor
|
||||
title="Workspace bots"
|
||||
workers={workers}
|
||||
selected_id={on_workspace_page ? undefined : selected_id}
|
||||
workspace_id={workspace.id ? Number(workspace.id) : null}
|
||||
hrefFor={(bot) => `${base}/bots/${bot.id}`}
|
||||
empty="No bots here yet."
|
||||
/>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -29,7 +29,7 @@ export const handler: BunextAPIRouteHandler<
|
||||
return { success: false, msg: `Unauthorized`, logoutUser: true };
|
||||
}
|
||||
|
||||
const { bot_id, workspace_id, query, page } = parseWithSchema({
|
||||
const { bot_id, workspace_id, query, page, limit } = parseWithSchema({
|
||||
schema: ListBotSessionsRequestSchema,
|
||||
data: body,
|
||||
});
|
||||
@@ -44,6 +44,7 @@ export const handler: BunextAPIRouteHandler<
|
||||
workspace_id: conversation.workspace_id,
|
||||
query,
|
||||
page,
|
||||
limit,
|
||||
}),
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
|
||||
@@ -63,6 +63,10 @@ export type BotConversationRequest = z.infer<typeof BotConversationRequestSchema
|
||||
export const ListBotSessionsRequestSchema = BotConversationRequestSchema.extend({
|
||||
query: z.string().trim().max(200).optional(),
|
||||
page: z.coerce.number().int().positive().optional(),
|
||||
/**
|
||||
* A handful for the rail's sub-list; the modal leaves it out for a page.
|
||||
*/
|
||||
limit: z.coerce.number().int().positive().max(AppData.BotSessionsPageSize).optional(),
|
||||
});
|
||||
|
||||
export type ListBotSessionsRequest = z.infer<typeof ListBotSessionsRequestSchema>;
|
||||
|
||||
Reference in New Issue
Block a user