- 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]>
155 lines
5.6 KiB
TypeScript
155 lines
5.6 KiB
TypeScript
import { useMemo, type RefObject } from "react";
|
|
import isAwaitingReply from "../(utils)/is-awaiting-reply";
|
|
import Stack from "@moduletrace/twui/layout/Stack";
|
|
import type {
|
|
BotMessageWithAttachments,
|
|
BotWithLastMessage,
|
|
} from "@/src/types";
|
|
import useMessageSelection from "../(hooks)/use-message-selection";
|
|
import useThreadScroll from "../(hooks)/use-thread-scroll";
|
|
import groupMessages from "../(utils)/group-messages";
|
|
import BotThinkingRow from "./bot-thinking-row";
|
|
import MessageGroupBlock from "./message-group-block";
|
|
import OlderMessagesLoader from "./older-messages-loader";
|
|
import QuoteSelectionButton from "./quote-selection-button";
|
|
import ScrollToBottomButton from "./scroll-to-bottom-button";
|
|
|
|
type Props = {
|
|
bot: BotWithLastMessage;
|
|
messages: BotMessageWithAttachments[];
|
|
/**
|
|
* The latest step of the bot's active task, shown while it works.
|
|
*/
|
|
progress?: string | null;
|
|
/**
|
|
* The latest step of the reply running here, shown while it thinks.
|
|
*/
|
|
reply_progress?: string | null;
|
|
/**
|
|
* The last message is still on its way to the server.
|
|
*/
|
|
receiving?: boolean;
|
|
/**
|
|
* 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.
|
|
*/
|
|
onQuote?: (params: { text: string }) => void;
|
|
/**
|
|
* The session's older messages, loaded at the top of the thread.
|
|
*/
|
|
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;
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Bottom-anchored: the stack sits 24px above the composer rule and grows
|
|
* 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, 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.
|
|
*/
|
|
export default function MessageThread({
|
|
bot,
|
|
messages,
|
|
progress,
|
|
reply_progress,
|
|
receiving,
|
|
has_task,
|
|
moved_on,
|
|
onQuote,
|
|
older,
|
|
}: Props) {
|
|
const groups = useMemo(() => groupMessages({ messages }), [messages]);
|
|
const scroll = useThreadScroll({ messages });
|
|
const { selection, clear } = useMessageSelection({ ref: scroll.column_ref });
|
|
|
|
return (
|
|
<Stack className="relative grow min-h-0 gap-0! items-stretch">
|
|
<Stack
|
|
componentRef={scroll.column_ref as RefObject<HTMLDivElement>}
|
|
className="grow min-h-0 gap-0! items-stretch overflow-y-auto flex-col-reverse"
|
|
onScroll={scroll.on_scroll}
|
|
>
|
|
<Stack
|
|
componentRef={scroll.content_ref as RefObject<HTMLDivElement>}
|
|
role="log"
|
|
aria-live="polite"
|
|
aria-label={`Conversation with ${bot.name}`}
|
|
className="w-full max-w-container mx-auto gap-4! items-stretch px-3 md:px-6 pt-6 pb-6"
|
|
>
|
|
{older?.has_more ? (
|
|
<OlderMessagesLoader
|
|
loading={older.loading}
|
|
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
|
|
key={group.key}
|
|
bot={bot}
|
|
group={group}
|
|
/>
|
|
))}
|
|
<BotThinkingRow
|
|
bot={bot}
|
|
progress={progress}
|
|
reply_progress={reply_progress}
|
|
awaiting_reply={
|
|
!moved_on &&
|
|
isAwaitingReply({
|
|
last_message: messages[messages.length - 1],
|
|
face: bot.face,
|
|
})
|
|
}
|
|
receiving={receiving}
|
|
has_task={has_task}
|
|
/>
|
|
</Stack>
|
|
</Stack>
|
|
{scroll.jump_visible ? (
|
|
<ScrollToBottomButton onClick={scroll.jump_to_bottom} />
|
|
) : null}
|
|
{selection && onQuote ? (
|
|
<QuoteSelectionButton
|
|
selection={selection}
|
|
onQuote={() => {
|
|
onQuote({ text: selection.text });
|
|
clear();
|
|
}}
|
|
/>
|
|
) : null}
|
|
</Stack>
|
|
);
|
|
}
|