Trim page props and page the conversation's messages
Bot and workspace lists in page props leave out instructions, memory and workspace context, and carry the last message as a short preview. The header avatar's edit form loads the bot's instructions when it opens. The conversation renders its latest 30 messages and loads older ones as the thread scrolls to its top. Also: the composer shows a placeholder until its editor loads, and `bun start` no longer builds first. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { useContext, useState } from "react";
|
||||
import Modal from "@moduletrace/twui/elements/Modal";
|
||||
import Loading from "@moduletrace/twui/elements/Loading";
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import H2 from "@moduletrace/twui/layout/H2";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
@@ -10,6 +11,7 @@ import type { BotWithLastMessage } from "@/src/types";
|
||||
import BotForm from "@/src/layouts/bots/(partials)/bot-form";
|
||||
import { AppContext } from "@/src/pages/__root";
|
||||
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
|
||||
import useBotInstructions from "../(hooks)/use-bot-instructions";
|
||||
|
||||
type Props = {
|
||||
bot: BotWithLastMessage;
|
||||
@@ -18,12 +20,17 @@ type Props = {
|
||||
/**
|
||||
* The header's avatar: opens the bot's profile (picture, name, job,
|
||||
* colour, instructions) to edit, for super admins and admins. Everyone
|
||||
* else sees the avatar alone.
|
||||
* else sees the avatar alone. The instructions load when it opens.
|
||||
*/
|
||||
export default function BotProfileModal({ bot }: Props) {
|
||||
const { pageProps } = useContext(AppContext);
|
||||
const [open, setOpen] = useState(false);
|
||||
const name = bot.name || "This bot";
|
||||
const can_edit = canManageCrew({ user_types: pageProps?.user_types });
|
||||
const { system_prompt, error } = useBotInstructions({
|
||||
bot_id: Number(bot.id) || 0,
|
||||
enabled: open && can_edit,
|
||||
});
|
||||
|
||||
const avatar = (
|
||||
<BotAvatar
|
||||
@@ -34,7 +41,7 @@ export default function BotProfileModal({ bot }: Props) {
|
||||
/>
|
||||
);
|
||||
|
||||
if (!canManageCrew({ user_types: pageProps?.user_types })) {
|
||||
if (!can_edit) {
|
||||
return <Stack className="shrink-0">{avatar}</Stack>;
|
||||
}
|
||||
|
||||
@@ -65,9 +72,15 @@ export default function BotProfileModal({ bot }: Props) {
|
||||
: `${name}'s core instructions apply everywhere. What it does in one workspace is set on that workspace's page.`}
|
||||
</Span>
|
||||
</Stack>
|
||||
{open ? (
|
||||
<BotForm bot={bot} onCancel={() => setOpen(false)} />
|
||||
) : null}
|
||||
{!open ? null : system_prompt != undefined ? (
|
||||
<BotForm bot={{ ...bot, system_prompt }} onCancel={() => setOpen(false)} />
|
||||
) : error ? (
|
||||
<Span className="py-6 text-[13px] leading-[18px] text-ink-secondary">{error}</Span>
|
||||
) : (
|
||||
<Stack className="w-full py-10 items-center">
|
||||
<Loading />
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { EditorContent, type Editor } from "@tiptap/react";
|
||||
import ComposerFormatBubble from "./composer-format-bubble";
|
||||
import LoadingBlock from "@moduletrace/twui/elements/LoadingBlock";
|
||||
|
||||
type Props = {
|
||||
editor: Editor | null;
|
||||
@@ -21,7 +22,11 @@ export default function ComposerEditor({ editor, invalid, title }: Props) {
|
||||
title={title}
|
||||
className="grow min-w-0 self-center"
|
||||
/>
|
||||
{editor ? <ComposerFormatBubble editor={editor} /> : null}
|
||||
{editor ? (
|
||||
<ComposerFormatBubble editor={editor} />
|
||||
) : (
|
||||
<LoadingBlock className="w-full h-[30px] -ml-6" />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { useContext, useEffect, useImperativeHandle, useRef, useState, type Ref } from "react";
|
||||
import {
|
||||
useContext,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
useState,
|
||||
type Ref,
|
||||
} from "react";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import P from "@moduletrace/twui/layout/P";
|
||||
@@ -125,7 +132,11 @@ export default function Composer({
|
||||
});
|
||||
const editor = input.editor;
|
||||
|
||||
useImperativeHandle(handle_ref, () => ({ insertQuote: input.insertQuote }), [input.insertQuote]);
|
||||
useImperativeHandle(
|
||||
handle_ref,
|
||||
() => ({ insertQuote: input.insertQuote }),
|
||||
[input.insertQuote],
|
||||
);
|
||||
|
||||
/**
|
||||
* The saved draft goes into the editor once per conversation, after
|
||||
@@ -134,7 +145,8 @@ export default function Composer({
|
||||
const applied_key = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor || !draft.loaded || applied_key.current == draft.key) return;
|
||||
if (!editor || !draft.loaded || applied_key.current == draft.key)
|
||||
return;
|
||||
|
||||
applied_key.current = draft.key;
|
||||
|
||||
@@ -156,7 +168,11 @@ export default function Composer({
|
||||
onRecorded: async ({ audio }) => {
|
||||
if (!bot.id) return;
|
||||
|
||||
const res = await transcribeVoice({ bot_id: bot.id, workspace_id, audio });
|
||||
const res = await transcribeVoice({
|
||||
bot_id: bot.id,
|
||||
workspace_id,
|
||||
audio,
|
||||
});
|
||||
|
||||
if (!res.success || !res.singleRes?.text) {
|
||||
recorder.setError(res.msg || "Couldn't write that out");
|
||||
@@ -294,7 +310,8 @@ export default function Composer({
|
||||
/>
|
||||
) : null}
|
||||
{input.menu ? <ComposerMentionList menu={input.menu} /> : null}
|
||||
<Row className={`w-full max-w-container mx-auto min-h-[88px] ${reply_to ? "pt-3 pb-7" : "py-7"} gap-3! md:gap-4! flex-nowrap items-end px-3 md:px-6 cursor-text`}
|
||||
<Row
|
||||
className={`w-full max-w-container mx-auto min-h-[88px] ${reply_to ? "pt-3 pb-7" : "py-7"} gap-3! md:gap-4! flex-nowrap items-end px-3 md:px-6 cursor-text`}
|
||||
onMouseDown={(e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
|
||||
@@ -308,7 +325,13 @@ export default function Composer({
|
||||
disabled={files.full}
|
||||
onFiles={files.add}
|
||||
/>
|
||||
<ComposerEditor editor={editor} invalid={Boolean(error)} title={error} />
|
||||
|
||||
<ComposerEditor
|
||||
editor={editor}
|
||||
invalid={Boolean(error)}
|
||||
title={error}
|
||||
/>
|
||||
|
||||
{recorder.supported ? (
|
||||
<ComposerVoiceButton
|
||||
state={recorder.state}
|
||||
@@ -327,11 +350,18 @@ export default function Composer({
|
||||
/>
|
||||
</Row>
|
||||
{recorder.error ? (
|
||||
<P role="alert" className="w-full max-w-container mx-auto px-3 md:px-6 -mt-5 pb-3 text-xs text-ink-secondary">
|
||||
<P
|
||||
role="alert"
|
||||
className="w-full max-w-container mx-auto px-3 md:px-6 -mt-5 pb-3 text-xs text-ink-secondary"
|
||||
>
|
||||
{recorder.error}
|
||||
</P>
|
||||
) : null}
|
||||
<ComposerTools bot={bot} workspace_id={workspace_id} initial={extensions} />
|
||||
<ComposerTools
|
||||
bot={bot}
|
||||
workspace_id={workspace_id}
|
||||
initial={extensions}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
@@ -8,6 +8,7 @@ import useMessageSelection from "../(hooks)/use-message-selection";
|
||||
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";
|
||||
|
||||
type Props = {
|
||||
@@ -33,6 +34,15 @@ type Props = {
|
||||
* 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;
|
||||
loading: boolean;
|
||||
error?: string;
|
||||
loadOlder: () => void;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -40,6 +50,8 @@ 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
|
||||
* the view where it was as they land above it.
|
||||
*/
|
||||
export default function MessageThread({
|
||||
bot,
|
||||
@@ -49,6 +61,7 @@ export default function MessageThread({
|
||||
receiving,
|
||||
has_task,
|
||||
onQuote,
|
||||
older,
|
||||
}: Props) {
|
||||
const groups = useMemo(() => groupMessages({ messages }), [messages]);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
@@ -64,6 +77,13 @@ export default function MessageThread({
|
||||
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}
|
||||
/>
|
||||
) : null}
|
||||
{groups.map((group) => (
|
||||
<MessageGroupBlock
|
||||
key={group.key}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useEffect, useRef, type RefObject } from "react";
|
||||
import Loading from "@moduletrace/twui/elements/Loading";
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
|
||||
type Props = {
|
||||
loading: boolean;
|
||||
error?: string;
|
||||
onLoad: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
export default function OlderMessagesLoader({ loading, error, onLoad }: Props) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const load = useRef(onLoad);
|
||||
|
||||
load.current = onLoad;
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
|
||||
if (!el || error || typeof IntersectionObserver == "undefined") return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries.some((entry) => entry.isIntersecting)) load.current();
|
||||
},
|
||||
{ rootMargin: "400px 0px 0px 0px" },
|
||||
);
|
||||
|
||||
observer.observe(el);
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [error, loading]);
|
||||
|
||||
return (
|
||||
<Stack
|
||||
componentRef={ref as RefObject<HTMLDivElement>}
|
||||
className="w-full items-center justify-center min-h-8"
|
||||
>
|
||||
{loading ? (
|
||||
<Loading size="smaller" />
|
||||
) : (
|
||||
<Button
|
||||
title="Show earlier messages"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="text"
|
||||
onClick={onLoad}
|
||||
className="text-[13px]! leading-[18px]! text-ink-secondary"
|
||||
>
|
||||
{error ? `${error} Try again` : "Show earlier messages"}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user