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:
2026-10-26 05:35:52 +01:00
co-authored by Claude Opus 5.5
parent 1e6833b5d8
commit cc109bc361
64 changed files with 2074 additions and 128 deletions
@@ -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>
);
}