Carry conversations into new sessions with a visible summary
- Bots get continue_in_new_session: the bot writes a title and summary, the session closes for good (read-only) and the new one opens with the summary as a "Carried over from the last session" message that links back to the old session. - Continue in new session (conversation menu, sessions modal) and the automatic rollover at the session length do the same; New session now starts a fresh, unlinked session. - Session length is an app setting in Settings > General. - ?session_id= opens a session; a carried-over one shows a bar linking to where the conversation went on instead of the composer. - The thread can open the previous session above the current one (shown only, never in the prompt), and replies get search_past_sessions and read_past_session. - Sessions are named from their opening messages, not their latest, and a carried summary is labelled as the earlier session's. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -8,6 +8,7 @@ import linkComputerRefs from "@/src/utils/link-computer-refs";
|
||||
import { BotMessageActionsContext } from "../(data)/message-actions-context";
|
||||
import { MessageMarkdownComponents } from "../(data)/message-markdown-components";
|
||||
import { QuotableAttribute } from "../(hooks)/use-message-selection";
|
||||
import CarriedOverChip from "./carried-over-chip";
|
||||
import GroupTimestamp from "./group-timestamp";
|
||||
import MessageActions from "./message-actions";
|
||||
import MessageReactions from "./message-reactions";
|
||||
@@ -34,7 +35,8 @@ type Props = {
|
||||
|
||||
/**
|
||||
* One bot message, rendered from markdown (GFM; raw HTML is never
|
||||
* rendered). Computers it names (`computerRef`) show as buttons that
|
||||
* rendered). The summary opening a session the bot moved into is
|
||||
* headed by a link to the session it carries on from. Computers it names (`computerRef`) show as buttons that
|
||||
* switch the bot to them. A quoted reply sits above it, its reactions
|
||||
* below, and a row under it replies, reacts, reads it aloud and shows
|
||||
* why it needs you. Its text can be selected and quoted.
|
||||
@@ -48,6 +50,13 @@ function BotMessageLine({ message, bot_name, at }: Props) {
|
||||
|
||||
return (
|
||||
<Stack className="w-full min-w-0 gap-1.5! items-start max-w-full">
|
||||
{message.carried_from_session_id && message.bot_id ? (
|
||||
<CarriedOverChip
|
||||
bot_id={message.bot_id}
|
||||
workspace_id={message.workspace_id}
|
||||
session_id={message.carried_from_session_id}
|
||||
/>
|
||||
) : null}
|
||||
{message.reply_to ? (
|
||||
<MessageReplyQuote
|
||||
reply_to={message.reply_to}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { History } from "lucide-react";
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import conversationHref from "@/src/utils/conversation-href";
|
||||
|
||||
type Props = {
|
||||
bot_id: number | string;
|
||||
workspace_id?: number | string | null;
|
||||
/**
|
||||
* The session the summary carries on from.
|
||||
*/
|
||||
session_id: number | string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Heads the summary that opens a session the bot moved the conversation
|
||||
* into (`continue_in_new_session`), and opens the session it carries on
|
||||
* from to read. The thread's "Show the previous session" shows the same
|
||||
* messages in place; this is the other way there, on purpose.
|
||||
*/
|
||||
export default function CarriedOverChip({ bot_id, workspace_id, session_id }: Props) {
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outlined"
|
||||
color="gray"
|
||||
size="smaller"
|
||||
title="Open the session this one carries on from"
|
||||
href={conversationHref({ bot_id, workspace_id, session_id })}
|
||||
beforeIcon={<History size={14} aria-hidden className="shrink-0" />}
|
||||
className="inline-flex h-6 gap-1.5! flex-nowrap align-middle px-2! text-[11px] font-bold whitespace-nowrap text-ink dark:text-ink bg-transparent hover:bg-sunken"
|
||||
>
|
||||
Carried over from the last session
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -2,10 +2,16 @@ import { useContext } from "react";
|
||||
import Paper from "@moduletrace/twui/elements/Paper";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import Divider from "@moduletrace/twui/layout/Divider";
|
||||
import { FolderOpen, History, Settings } from "lucide-react";
|
||||
import {
|
||||
FolderOpen,
|
||||
History,
|
||||
MessageSquareShare,
|
||||
Settings,
|
||||
} from "lucide-react";
|
||||
import type { BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
|
||||
import type { BotWithLastMessage, HarnessOverview } from "@/src/types";
|
||||
import BotSessionsModal from "@/src/components/general/bot-sessions-modal";
|
||||
import ContinueSessionButton from "@/src/components/general/continue-session-button";
|
||||
import { AppContext } from "@/src/pages/__root";
|
||||
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
|
||||
import isPassThroughUser from "@/src/utils/rbac/is-pass-through-user";
|
||||
@@ -58,7 +64,8 @@ type Props = {
|
||||
};
|
||||
|
||||
/**
|
||||
* The conversation header's menu: the sessions, a new session, copying
|
||||
* The conversation header's menu: the sessions, a new (fresh) session,
|
||||
* continuing in a new session with a summary of this one, copying
|
||||
* the session, its files, tasks, routines and hooks, the bot's computer, its models, in a workspace its
|
||||
* instructions there and making it the admin there, its
|
||||
* settings and, while it works, stopping it. A
|
||||
@@ -84,7 +91,9 @@ export default function ConversationHeaderMenu({
|
||||
/**
|
||||
* Hooks start work, so a read-only user doesn't get them.
|
||||
*/
|
||||
const can_make_hooks = !isReadOnlyUser({ user_types: pageProps?.user_types });
|
||||
const can_make_hooks = !isReadOnlyUser({
|
||||
user_types: pageProps?.user_types,
|
||||
});
|
||||
/**
|
||||
* A bot's instructions for one workspace, only there, for crew
|
||||
* managers (the coordinator's too: only the super admin sees it).
|
||||
@@ -98,8 +107,8 @@ export default function ConversationHeaderMenu({
|
||||
*/
|
||||
const can_set_admin = Boolean(
|
||||
workspace?.id &&
|
||||
bot.kind != "coordinator" &&
|
||||
canManageCrew({ user_types: pageProps?.user_types }),
|
||||
bot.kind != "coordinator" &&
|
||||
canManageCrew({ user_types: pageProps?.user_types }),
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -133,6 +142,19 @@ export default function ConversationHeaderMenu({
|
||||
workspace_id={workspace?.id || null}
|
||||
on_open={on_open}
|
||||
/>
|
||||
<ContinueSessionButton
|
||||
bot_id={Number(bot.id)}
|
||||
workspace_id={workspace?.id || null}
|
||||
render={({ loading, onClick }) => (
|
||||
<HeaderMenuItem
|
||||
icon={MessageSquareShare}
|
||||
title="Continue in a new session"
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
onClick={onClick}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<CopyChatMenuItem
|
||||
bot_id={Number(bot.id)}
|
||||
workspace_id={workspace?.id || null}
|
||||
@@ -157,7 +179,11 @@ export default function ConversationHeaderMenu({
|
||||
on_open={on_open}
|
||||
/>
|
||||
{can_make_hooks ? (
|
||||
<BotHooksModal bot={bot} workspace={workspace} on_open={on_open} />
|
||||
<BotHooksModal
|
||||
bot={bot}
|
||||
workspace={workspace}
|
||||
on_open={on_open}
|
||||
/>
|
||||
) : null}
|
||||
<BotComputersModal
|
||||
menu
|
||||
|
||||
@@ -2,16 +2,31 @@ import type { BotWithLastMessage } from "@/src/types";
|
||||
import type { MessageGroup } from "../(utils)/group-messages";
|
||||
import UserMessageGroup from "./user-message-group";
|
||||
import BotMessageGroup from "./bot-message-group";
|
||||
import SessionDivider from "./session-divider";
|
||||
|
||||
type Props = {
|
||||
bot: BotWithLastMessage;
|
||||
group: MessageGroup;
|
||||
};
|
||||
|
||||
/**
|
||||
* One author's group, under a divider when it starts a later session than
|
||||
* the messages above it.
|
||||
*/
|
||||
export default function MessageGroupBlock({ bot, group }: Props) {
|
||||
if (group.author === "user") {
|
||||
return <UserMessageGroup bot={bot} group={group} />;
|
||||
}
|
||||
const block =
|
||||
group.author === "user" ? (
|
||||
<UserMessageGroup bot={bot} group={group} />
|
||||
) : (
|
||||
<BotMessageGroup bot={bot} group={group} />
|
||||
);
|
||||
|
||||
return <BotMessageGroup bot={bot} group={group} />;
|
||||
if (!group.starts_session) return block;
|
||||
|
||||
return (
|
||||
<>
|
||||
<SessionDivider />
|
||||
{block}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -33,6 +33,10 @@ type Props = {
|
||||
* This session has an active task.
|
||||
*/
|
||||
has_task?: boolean;
|
||||
/**
|
||||
* The conversation went on in another session: no reply is due here.
|
||||
*/
|
||||
moved_on?: boolean;
|
||||
/**
|
||||
* Quotes text selected in a message in the composer.
|
||||
*/
|
||||
@@ -42,9 +46,15 @@ type Props = {
|
||||
*/
|
||||
older?: {
|
||||
has_more: boolean;
|
||||
/**
|
||||
* The thread reaches the start of its oldest session, which carries
|
||||
* on from another one that can be opened above it.
|
||||
*/
|
||||
has_previous?: boolean;
|
||||
loading: boolean;
|
||||
error?: string;
|
||||
loadOlder: () => void;
|
||||
loadPrevious?: () => void;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -53,7 +63,9 @@ type Props = {
|
||||
* upward. Empty space belongs at the top. New messages announce politely.
|
||||
* While the bot works on a reply or a task, a status row sits under the
|
||||
* last one. Text selected in a message can be quoted in the composer.
|
||||
* Older messages load as it scrolls to the top; the reversed column keeps
|
||||
* Older messages load as it scrolls to the top, and at the start of a
|
||||
* session that carries on from another, the earlier one can be opened
|
||||
* above it (shown only, never put in the bot's prompt); the reversed column keeps
|
||||
* the view where it was as they land above it. The thread follows new
|
||||
* messages while it sits at its bottom, stays put while it's scrolled up
|
||||
* and then offers a button to get back down.
|
||||
@@ -65,6 +77,7 @@ export default function MessageThread({
|
||||
reply_progress,
|
||||
receiving,
|
||||
has_task,
|
||||
moved_on,
|
||||
onQuote,
|
||||
older,
|
||||
}: Props) {
|
||||
@@ -92,6 +105,14 @@ export default function MessageThread({
|
||||
error={older.error}
|
||||
onLoad={older.loadOlder}
|
||||
/>
|
||||
) : older?.has_previous && older.loadPrevious ? (
|
||||
<OlderMessagesLoader
|
||||
loading={older.loading}
|
||||
error={older.error}
|
||||
onLoad={older.loadPrevious}
|
||||
label="Show the previous session"
|
||||
manual
|
||||
/>
|
||||
) : null}
|
||||
{groups.map((group) => (
|
||||
<MessageGroupBlock
|
||||
@@ -104,10 +125,13 @@ export default function MessageThread({
|
||||
bot={bot}
|
||||
progress={progress}
|
||||
reply_progress={reply_progress}
|
||||
awaiting_reply={isAwaitingReply({
|
||||
last_message: messages[messages.length - 1],
|
||||
face: bot.face,
|
||||
})}
|
||||
awaiting_reply={
|
||||
!moved_on &&
|
||||
isAwaitingReply({
|
||||
last_message: messages[messages.length - 1],
|
||||
face: bot.face,
|
||||
})
|
||||
}
|
||||
receiving={receiving}
|
||||
has_task={has_task}
|
||||
/>
|
||||
|
||||
@@ -7,13 +7,28 @@ type Props = {
|
||||
loading: boolean;
|
||||
error?: string;
|
||||
onLoad: () => void;
|
||||
/**
|
||||
* Defaults to "Show earlier messages".
|
||||
*/
|
||||
label?: string;
|
||||
/**
|
||||
* Loads only on a click, never as it scrolls into view.
|
||||
*/
|
||||
manual?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* The top of a thread with older messages: loads them as it scrolls into
|
||||
* view, with a button for when it can't (or a load failed).
|
||||
* view (unless `manual`), with a button for when it can't (or a load
|
||||
* failed).
|
||||
*/
|
||||
export default function OlderMessagesLoader({ loading, error, onLoad }: Props) {
|
||||
export default function OlderMessagesLoader({
|
||||
loading,
|
||||
error,
|
||||
onLoad,
|
||||
label = "Show earlier messages",
|
||||
manual,
|
||||
}: Props) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const load = useRef(onLoad);
|
||||
|
||||
@@ -22,7 +37,7 @@ export default function OlderMessagesLoader({ loading, error, onLoad }: Props) {
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
|
||||
if (!el || error || typeof IntersectionObserver == "undefined") return;
|
||||
if (!el || error || manual || typeof IntersectionObserver == "undefined") return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
@@ -34,7 +49,7 @@ export default function OlderMessagesLoader({ loading, error, onLoad }: Props) {
|
||||
observer.observe(el);
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [error, loading]);
|
||||
}, [error, loading, manual]);
|
||||
|
||||
return (
|
||||
<Stack
|
||||
@@ -45,14 +60,14 @@ export default function OlderMessagesLoader({ loading, error, onLoad }: Props) {
|
||||
<Loading size="smaller" />
|
||||
) : (
|
||||
<Button
|
||||
title="Show earlier messages"
|
||||
title={label}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="text"
|
||||
onClick={onLoad}
|
||||
className="text-[13px]! leading-[18px]! text-ink-secondary"
|
||||
>
|
||||
{error ? `${error} Try again` : "Show earlier messages"}
|
||||
{error ? `${error} Try again` : label}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import Divider from "@moduletrace/twui/layout/Divider";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
|
||||
/**
|
||||
* Between an earlier session opened above the thread and the session
|
||||
* that carries on from it. What's above is shown only: the bot doesn't
|
||||
* read it unless it looks it up.
|
||||
*/
|
||||
export default function SessionDivider() {
|
||||
return (
|
||||
<Row className="w-full flex-nowrap gap-4! items-center">
|
||||
<Divider className="border-hairline dark:border-hairline" />
|
||||
<Span className="shrink-0 text-[13px] leading-[18px] text-ink-secondary">
|
||||
New session
|
||||
</Span>
|
||||
<Divider className="border-hairline dark:border-hairline" />
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { ArrowRight } from "lucide-react";
|
||||
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 conversationHref from "@/src/utils/conversation-href";
|
||||
|
||||
type Props = {
|
||||
bot_id: number;
|
||||
workspace_id?: number | null;
|
||||
/**
|
||||
* The session the one on screen was carried into.
|
||||
*/
|
||||
moved_to_session_id: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Takes the composer's place under a session that was carried over into
|
||||
* a newer one (by the bot, Continue in new session or the rollover): it
|
||||
* stays closed, to read, and points to where the conversation went on.
|
||||
*/
|
||||
export default function SessionMovedBar({ bot_id, workspace_id, moved_to_session_id }: Props) {
|
||||
return (
|
||||
<Stack className="shrink-0 gap-0! items-stretch border-0 border-t border-solid border-hairline">
|
||||
<Row className="w-full max-w-container mx-auto min-h-[88px] py-7 gap-3! md:gap-4! flex-nowrap items-center justify-between px-3 md:px-6">
|
||||
<Span className="text-sm text-ink-secondary">
|
||||
This conversation moved to a new session. This one stays as it was, to read.
|
||||
</Span>
|
||||
<Button
|
||||
title="Continue in the newer session"
|
||||
type="button"
|
||||
size="small"
|
||||
href={conversationHref({ bot_id, workspace_id, session_id: moved_to_session_id })}
|
||||
afterIcon={<ArrowRight size={14} aria-hidden />}
|
||||
variant="outlined"
|
||||
className="shrink-0"
|
||||
>
|
||||
Continue there
|
||||
</Button>
|
||||
</Row>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user