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:
@@ -12,31 +12,42 @@ type Props = {
|
||||
loading?: boolean;
|
||||
disabled?: boolean;
|
||||
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
|
||||
* message, how long and how recent. The active one says so; any other
|
||||
* reopens on click.
|
||||
* message, how long and how recent. The one on screen is
|
||||
* 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) {
|
||||
const active = session.status == "active";
|
||||
export default function BotSessionRow({ session, loading, disabled, onOpen, current }: Props) {
|
||||
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"}"`}
|
||||
title={
|
||||
current
|
||||
? "This is the session on screen"
|
||||
: session.status == "active"
|
||||
? "Go back to the active session"
|
||||
: `Reopen "${session.title || "Untitled"}"`
|
||||
}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="text"
|
||||
loading={loading}
|
||||
disabled={active || disabled}
|
||||
disabled={current || 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!",
|
||||
current && "bg-sunken hover:bg-sunken hover:dark:bg-sunken opacity-100!",
|
||||
)}
|
||||
>
|
||||
<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
|
||||
className={twMerge(
|
||||
"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"}
|
||||
</Span>
|
||||
{session.unread && !active ? (
|
||||
{session.unread && !current ? (
|
||||
<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) }) : ""}
|
||||
{current ? "Current" : at ? relativeTime({ at: Number(at) }) : ""}
|
||||
</Span>
|
||||
</Row>
|
||||
<Span className="w-full truncate text-xs leading-4 text-ink-secondary">
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import ContinueSessionButton from "./continue-session-button";
|
||||
import useShownSession from "@/src/hooks/use-shown-session";
|
||||
import conversationHref from "@/src/utils/conversation-href";
|
||||
import {
|
||||
useEffect,
|
||||
useState,
|
||||
@@ -52,10 +54,12 @@ export default function BotSessionsModal({
|
||||
on_open,
|
||||
}: Props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const on_screen = useShownSession({ bot_id: Number(bot.id), workspace_id });
|
||||
const sessions = useBotSessions({
|
||||
bot_id: Number(bot.id),
|
||||
workspace_id,
|
||||
open,
|
||||
refresh_key: on_screen ? `${on_screen.id}:${on_screen.status}` : null,
|
||||
});
|
||||
const name = bot.name || "the bot";
|
||||
|
||||
@@ -141,10 +145,13 @@ export default function BotSessionsModal({
|
||||
session={session}
|
||||
loading={sessions.busy_id == session.id}
|
||||
disabled={Boolean(sessions.busy_id)}
|
||||
current={on_screen?.id ? on_screen.id == session.id : session.status == "active"}
|
||||
onOpen={() =>
|
||||
sessions.reopenSession({
|
||||
session_id: Number(session.id),
|
||||
})
|
||||
session.status == "active"
|
||||
? 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 }));
|
||||
}
|
||||
@@ -15,6 +15,11 @@ type Params = {
|
||||
* How many to load, latest first; the modal leaves it out for a page.
|
||||
*/
|
||||
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 = {
|
||||
@@ -51,7 +56,7 @@ function belongsToConversation({
|
||||
* session started in the conversation (or named once its first reply is
|
||||
* 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 [sessions, setSessions] = useState<BotSessionListItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -111,7 +116,7 @@ export default function useBotSessions({ bot_id, workspace_id, open, limit }: Pa
|
||||
stale = true;
|
||||
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> }) => {
|
||||
if (busy_id) return;
|
||||
|
||||
@@ -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;
|
||||
disabled?: boolean;
|
||||
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
|
||||
* bot's name, its title and how recent. The active one says so; any other
|
||||
* reopens on click.
|
||||
* bot's name, its title and how recent. The one on screen is
|
||||
* Current (greyed out); any other opens on click (reopening a closed one,
|
||||
* going back to the active one).
|
||||
*/
|
||||
export default function RailBotSessionRow({
|
||||
session,
|
||||
loading,
|
||||
disabled,
|
||||
onOpen,
|
||||
current,
|
||||
}: 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"}"`
|
||||
current
|
||||
? "This is the session on screen"
|
||||
: session.status == "active"
|
||||
? "Go back to the active session"
|
||||
: `Reopen "${session.title || "Untitled"}"`
|
||||
}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="text"
|
||||
loading={loading}
|
||||
disabled={active || disabled}
|
||||
disabled={current || 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-transparent hover:bg-transparent hover:dark:bg-transparent opacity-100!",
|
||||
current &&
|
||||
"bg-transparent hover:bg-transparent hover:dark:bg-transparent opacity-100!",
|
||||
)}
|
||||
loadingProps={{
|
||||
size: "smaller",
|
||||
@@ -51,18 +59,18 @@ export default function RailBotSessionRow({
|
||||
<Span
|
||||
className={twMerge(
|
||||
"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"}
|
||||
</Span>
|
||||
{session.unread && !active ? (
|
||||
{session.unread && !current ? (
|
||||
<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
|
||||
? "Current"
|
||||
: 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 { AppData } from "@/src/data/app-data";
|
||||
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 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
|
||||
* conversation's sessions modal. Hidden when the rail collapses to icons.
|
||||
*/
|
||||
export default function RailBotSessions({ bot, workspace_id }: Props) {
|
||||
const on_screen = useShownSession({ bot_id: Number(bot.id), workspace_id });
|
||||
const sessions = useBotSessions({
|
||||
bot_id: Number(bot.id),
|
||||
workspace_id,
|
||||
open: true,
|
||||
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;
|
||||
|
||||
@@ -41,7 +55,12 @@ export default function RailBotSessions({ bot, workspace_id }: Props) {
|
||||
session={session}
|
||||
loading={sessions.busy_id == session.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>
|
||||
|
||||
@@ -40,6 +40,7 @@ import mergeLoadedHistory from "../(utils)/merge-loaded-history";
|
||||
import LoadingLine from "@/src/components/general/loading-line";
|
||||
import SessionMovedBar from "../(partials)/session-moved-bar";
|
||||
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
|
||||
@@ -172,6 +173,11 @@ export default function Conversation() {
|
||||
setSessionId(loaded_session_id);
|
||||
setMovedToSessionId(history.moved_to_session_id || null);
|
||||
syncSessionQuery({ session: history.session });
|
||||
announceShownSession({
|
||||
bot_id: Number(bot?.id) || 0,
|
||||
workspace_id,
|
||||
session: history.session,
|
||||
});
|
||||
setMessages((prev) => mergeLoadedHistory({ shown: prev, history }));
|
||||
older.reset({
|
||||
session_id: loaded_session_id,
|
||||
|
||||
@@ -1581,6 +1581,16 @@ export type ConversationExportSession = {
|
||||
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 = {
|
||||
history?: ConversationHistory;
|
||||
redirect?: string;
|
||||
|
||||
Reference in New Issue
Block a user