Finish the mobile performance pass
- Cache the model catalog once per user and each conversation's setting separately; clear both on logout and after model saves - Compress hashed assets asynchronously with a byte budget and set Vary - Memoize message parsing and throttle selection reads - Debounce draft writes to localStorage - Keep the composer editor on the page and focused, as before - Add a bundle budget check and run it in CI - Show a loading block while sessions load and a workspace avatar in the mobile conversation header
This commit is contained in:
@@ -15,6 +15,12 @@ import MessageReplyQuote from "./message-reply-quote";
|
||||
import NeedsYouChip from "./needs-you-chip";
|
||||
import SpeakMessageButton from "./speak-message-button";
|
||||
|
||||
/**
|
||||
* Kept module-level: a new array on every render makes `react-markdown`
|
||||
* parse the message again.
|
||||
*/
|
||||
const RemarkPlugins = [remarkGfm];
|
||||
|
||||
type Props = {
|
||||
message: BotMessageWithAttachments;
|
||||
bot_name: string;
|
||||
@@ -52,7 +58,7 @@ function BotMessageLine({ message, bot_name, at }: Props) {
|
||||
className="w-full gap-2.5! items-start break-words"
|
||||
>
|
||||
<Markdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
remarkPlugins={RemarkPlugins}
|
||||
components={MessageMarkdownComponents}
|
||||
>
|
||||
{linkComputerRefs({ text: message.text || "" })}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useContext, useState } from "react";
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
import Modal from "@moduletrace/twui/elements/Modal";
|
||||
import H2 from "@moduletrace/twui/layout/H2";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
@@ -41,6 +41,15 @@ export default function BotModelsModal({ bot, workspace, onSaved }: Props) {
|
||||
* The pointer or focus reached the icon: start loading before the click.
|
||||
*/
|
||||
const [warm, setWarm] = useState(false);
|
||||
/**
|
||||
* The form has edits that a background refresh must not replace.
|
||||
*/
|
||||
const [dirty, setDirty] = useState(false);
|
||||
/**
|
||||
* The key the form remounts on. It only moves while the form is clean,
|
||||
* so a fetch that lands mid-edit leaves the edits alone.
|
||||
*/
|
||||
const [form_key, setFormKey] = useState(0);
|
||||
|
||||
const in_workspace = Boolean(workspace?.id);
|
||||
const is_coordinator = bot.kind == "coordinator" && !in_workspace;
|
||||
@@ -50,11 +59,20 @@ export default function BotModelsModal({ bot, workspace, onSaved }: Props) {
|
||||
const name = bot.name || "This bot";
|
||||
|
||||
const { harnesses, error, version, setHarnesses } = useHarnessOverview({
|
||||
user_id: Number(pageProps?.user?.id || 0),
|
||||
bot_id: Number(bot.id),
|
||||
workspace_id: workspace?.id || null,
|
||||
enabled: (open || warm) && can_edit,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!dirty) setFormKey(version);
|
||||
}, [dirty, version]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) setDirty(false);
|
||||
}, [open]);
|
||||
|
||||
if (!bot.id) return null;
|
||||
|
||||
const target = (
|
||||
@@ -107,10 +125,11 @@ export default function BotModelsModal({ bot, workspace, onSaved }: Props) {
|
||||
</Stack>
|
||||
{harnesses?.length ? (
|
||||
<BotHarnessForm
|
||||
key={version}
|
||||
key={form_key}
|
||||
bot={bot}
|
||||
workspace_id={workspace?.id || null}
|
||||
harnesses={harnesses}
|
||||
onDirtyChange={setDirty}
|
||||
onSaved={(fresh) => {
|
||||
setHarnesses(fresh);
|
||||
onSaved(fresh);
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
|
||||
/**
|
||||
* Holds the editor's place, the same size, while it loads.
|
||||
*/
|
||||
export default function ComposerInputFallback({ placeholder }: { placeholder: string }) {
|
||||
return (
|
||||
<Span className="grow min-w-0 self-center min-h-5 text-sm leading-5 text-ink-secondary truncate">
|
||||
{placeholder}
|
||||
</Span>
|
||||
);
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { useEffect, type RefObject } from "react";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import useComposerEditor, { type ComposerMentionMenu } from "../(hooks)/use-composer-editor";
|
||||
import ComposerEditor from "./composer-editor";
|
||||
|
||||
export type ComposerInputApi = ReturnType<typeof useComposerEditor>;
|
||||
|
||||
type Props = Parameters<typeof useComposerEditor>[0] & {
|
||||
/**
|
||||
* Gets the editor's commands (`setMarkdown`, `clear`, `insertText`,
|
||||
* `insertQuote`) for the composer around it.
|
||||
*/
|
||||
api_ref: RefObject<ComposerInputApi | null>;
|
||||
onEditor: (editor: Editor | null) => void;
|
||||
onMenu: (menu: ComposerMentionMenu | null) => void;
|
||||
invalid?: boolean;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* The composer's Tiptap editor, loaded on its own after the page (the
|
||||
* composer imports it lazily: Tiptap is the heaviest code on the page).
|
||||
* Hands its editor, mention menu and commands up to the composer.
|
||||
*/
|
||||
export default function ComposerInput({ api_ref, onEditor, onMenu, invalid, title, ...params }: Props) {
|
||||
const input = useComposerEditor(params);
|
||||
|
||||
api_ref.current = input;
|
||||
|
||||
useEffect(() => {
|
||||
onEditor(input.editor);
|
||||
}, [input.editor]);
|
||||
|
||||
useEffect(() => {
|
||||
onMenu(input.menu);
|
||||
}, [input.menu]);
|
||||
|
||||
useEffect(() => () => {
|
||||
api_ref.current = null;
|
||||
}, []);
|
||||
|
||||
return <ComposerEditor editor={input.editor} invalid={invalid} title={title} />;
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { lazy, Suspense, useContext, useEffect, useImperativeHandle, useRef, useState, type Ref } from "react";
|
||||
import type { Editor } from "@tiptap/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";
|
||||
@@ -15,25 +14,18 @@ import sendBotMessage from "../(functions)/send-bot-message";
|
||||
import transcribeVoice from "../(functions)/transcribe-voice";
|
||||
import useComposerAttachments from "../(hooks)/use-composer-attachments";
|
||||
import useComposerDraft from "../(hooks)/use-composer-draft";
|
||||
import type { ComposerMentionMenu } from "../(hooks)/use-composer-editor";
|
||||
import useComposerEditor from "../(hooks)/use-composer-editor";
|
||||
import useVoiceRecorder from "../(hooks)/use-voice-recorder";
|
||||
import useOnlineStatus from "@/src/hooks/use-online-status";
|
||||
import ComposerAttachButton from "./composer-attach-button";
|
||||
import ComposerAttachments from "./composer-attachments";
|
||||
import ComposerInputFallback from "./composer-input-fallback";
|
||||
import type { ComposerInputApi } from "./composer-input";
|
||||
import ComposerEditor from "./composer-editor";
|
||||
import ComposerMentionList from "./composer-mention-list";
|
||||
import ComposerReplyPreview from "./composer-reply-preview";
|
||||
import ComposerSendButton from "./composer-send-button";
|
||||
import ComposerTools from "./composer-tools";
|
||||
import ComposerVoiceButton from "./composer-voice-button";
|
||||
|
||||
/**
|
||||
* Tiptap is the heaviest code on the page, so the editor loads on its own
|
||||
* once the page is up; a stand-in holds its place until then.
|
||||
*/
|
||||
const ComposerInput = lazy(() => import("./composer-input"));
|
||||
|
||||
/**
|
||||
* What the conversation can do to the composer from outside it.
|
||||
*/
|
||||
@@ -68,7 +60,7 @@ type Props = {
|
||||
};
|
||||
|
||||
/**
|
||||
* A Tiptap editor (`useComposerEditor`, loaded lazily by `ComposerInput`) writing markdown: Enter sends,
|
||||
* A Tiptap editor (`useComposerEditor`) writing markdown: Enter sends,
|
||||
* Shift+Enter adds a line, a selection can be formatted, `@` mentions a
|
||||
* bot and `#` a workspace. Grows to eight lines, then scrolls.
|
||||
* Its top rule lines up with the rail's user-row rule; picked files sit
|
||||
@@ -98,16 +90,17 @@ export default function Composer({
|
||||
const workspaces = useContext(CrewWorkspacesContext);
|
||||
const send_ref = useRef<() => void>(() => {});
|
||||
|
||||
const input = useRef<ComposerInputApi | null>(null);
|
||||
const [editor, setEditor] = useState<Editor | null>(null);
|
||||
const [menu, setMenu] = useState<ComposerMentionMenu | null>(null);
|
||||
const placeholder = `Message ${bot.name}`;
|
||||
const input = useComposerEditor({
|
||||
placeholder: `Message ${bot.name}`,
|
||||
bots,
|
||||
workspaces,
|
||||
onChange: ({ markdown }) => setText(markdown),
|
||||
onSubmit: () => send_ref.current(),
|
||||
onFiles: files.add,
|
||||
});
|
||||
const editor = input.editor;
|
||||
|
||||
useImperativeHandle(
|
||||
handle_ref,
|
||||
() => ({ insertQuote: ({ text }) => input.current?.insertQuote({ text }) }),
|
||||
[],
|
||||
);
|
||||
useImperativeHandle(handle_ref, () => ({ insertQuote: input.insertQuote }), [input.insertQuote]);
|
||||
|
||||
/**
|
||||
* The saved draft goes into the editor once per conversation, after
|
||||
@@ -122,7 +115,7 @@ export default function Composer({
|
||||
|
||||
/** Out of React's render, since mention chips mount as it loads. */
|
||||
queueMicrotask(() => {
|
||||
input.current?.setMarkdown({ markdown: text });
|
||||
input.setMarkdown({ markdown: text });
|
||||
editor.commands.focus("end");
|
||||
});
|
||||
}, [editor, draft.loaded, draft.key]);
|
||||
@@ -145,7 +138,7 @@ export default function Composer({
|
||||
return;
|
||||
}
|
||||
|
||||
input.current?.insertText({ text: res.singleRes.text });
|
||||
input.insertText({ text: res.singleRes.text });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -178,7 +171,7 @@ export default function Composer({
|
||||
}
|
||||
|
||||
setText("");
|
||||
input.current?.clear();
|
||||
input.clear();
|
||||
files.clear();
|
||||
onSent({ message: res.singleRes });
|
||||
}
|
||||
@@ -211,7 +204,7 @@ export default function Composer({
|
||||
onCancel={onCancelReply}
|
||||
/>
|
||||
) : null}
|
||||
{menu ? <ComposerMentionList menu={menu} /> : 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`}
|
||||
onMouseDown={(e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
@@ -226,21 +219,7 @@ export default function Composer({
|
||||
disabled={files.full}
|
||||
onFiles={files.add}
|
||||
/>
|
||||
<Suspense fallback={<ComposerInputFallback placeholder={placeholder} />}>
|
||||
<ComposerInput
|
||||
api_ref={input}
|
||||
onEditor={setEditor}
|
||||
onMenu={setMenu}
|
||||
placeholder={placeholder}
|
||||
bots={bots}
|
||||
workspaces={workspaces}
|
||||
onChange={({ markdown }) => setText(markdown)}
|
||||
onSubmit={() => send_ref.current()}
|
||||
onFiles={files.add}
|
||||
invalid={Boolean(error)}
|
||||
title={error}
|
||||
/>
|
||||
</Suspense>
|
||||
<ComposerEditor editor={editor} invalid={Boolean(error)} title={error} />
|
||||
{recorder.supported ? (
|
||||
<ComposerVoiceButton
|
||||
state={recorder.state}
|
||||
|
||||
@@ -17,6 +17,10 @@ import NewSessionButton from "./new-session-button";
|
||||
import HeaderIconButton from "@/src/components/general/header-icon-button";
|
||||
import RailDrawerButton from "@/src/layouts/bots/(partials)/rail-drawer-button";
|
||||
import HeaderMetaItem from "./header-meta-item";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import Img from "@moduletrace/twui/layout/Img";
|
||||
import grabMediaURL from "@/src/functions/backend/media/grab-media-url";
|
||||
import WorkspaceAvatar from "@/src/components/general/workspace-avatar";
|
||||
|
||||
type Props = {
|
||||
bot: BotWithLastMessage;
|
||||
@@ -34,7 +38,11 @@ type Props = {
|
||||
* that open the computers and models modals. Kept here so a save or a
|
||||
* switch shows under the name at once.
|
||||
*/
|
||||
export default function ConversationHeader({ bot, workspace, has_other_unread }: Props) {
|
||||
export default function ConversationHeader({
|
||||
bot,
|
||||
workspace,
|
||||
has_other_unread,
|
||||
}: Props) {
|
||||
const { computers } = useContext(BotComputersContext);
|
||||
const { pageProps } = useContext(AppContext);
|
||||
const [model, setModel] = useState(pageProps?.bot_text_model);
|
||||
@@ -52,9 +60,27 @@ export default function ConversationHeader({ bot, workspace, has_other_unread }:
|
||||
<H1 className="text-lg! leading-6! tracking-normal! font-bold text-ink truncate w-full">
|
||||
{bot.name}
|
||||
</H1>
|
||||
<Row className="flex md:hidden min-w-0 w-full gap-x-3! gap-y-0! flex-nowrap">
|
||||
{workspace?.id ? (
|
||||
<Row className="gap-2! mb-0.5">
|
||||
<WorkspaceAvatar
|
||||
image_media_id={workspace?.image_media_id}
|
||||
size="xs"
|
||||
/>
|
||||
<Span size="smaller">
|
||||
{workspace.name || "Workspace"}
|
||||
</Span>
|
||||
</Row>
|
||||
) : null}
|
||||
</Row>
|
||||
|
||||
<Row className="hidden md:flex min-w-0 w-full gap-x-3! gap-y-0! flex-nowrap">
|
||||
{workspace?.id ? (
|
||||
<HeaderMetaItem icon={Layers} label="Workspace" text={workspace.name || "Workspace"} />
|
||||
<HeaderMetaItem
|
||||
icon={Layers}
|
||||
label="Workspace"
|
||||
text={workspace.name || "Workspace"}
|
||||
/>
|
||||
) : null}
|
||||
<HeaderMetaItem
|
||||
icon={CpuAi}
|
||||
@@ -80,17 +106,36 @@ export default function ConversationHeader({ bot, workspace, has_other_unread }:
|
||||
workspace_id={workspace?.id || null}
|
||||
has_other_unread={has_other_unread}
|
||||
/>
|
||||
<NewSessionButton bot_id={Number(bot.id)} workspace_id={workspace?.id || null} />
|
||||
<BotComputersModal bot={bot} workspace_name={workspace?.id ? workspace.name || "This workspace" : null} />
|
||||
<NewSessionButton
|
||||
bot_id={Number(bot.id)}
|
||||
workspace_id={workspace?.id || null}
|
||||
/>
|
||||
<BotComputersModal
|
||||
bot={bot}
|
||||
workspace_name={
|
||||
workspace?.id
|
||||
? workspace.name || "This workspace"
|
||||
: null
|
||||
}
|
||||
/>
|
||||
<span className="hidden md:inline-flex">
|
||||
<BotModelsModal
|
||||
key={bot.id}
|
||||
bot={bot}
|
||||
workspace={workspace}
|
||||
onSaved={(harnesses) => {
|
||||
const current = harnesses.find((h) => h.harness == "text")?.current;
|
||||
const current = harnesses.find(
|
||||
(h) => h.harness == "text",
|
||||
)?.current;
|
||||
|
||||
setModel(current ? { name: current.name, provider_name: current.provider_name } : null);
|
||||
setModel(
|
||||
current
|
||||
? {
|
||||
name: current.name,
|
||||
provider_name: current.provider_name,
|
||||
}
|
||||
: null,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
|
||||
@@ -6,6 +6,12 @@ import linkComputerRefs from "@/src/utils/link-computer-refs";
|
||||
import { UserMarkdownComponents } from "../(data)/user-markdown-components";
|
||||
import { QuotableAttribute } from "../(hooks)/use-message-selection";
|
||||
|
||||
/**
|
||||
* Kept module-level: a new array on every render makes `react-markdown`
|
||||
* parse the message again.
|
||||
*/
|
||||
const RemarkPlugins = [remarkGfm, remarkBreaks];
|
||||
|
||||
/**
|
||||
* Ink bubble with paper text; inverts in dark mode. The text is markdown
|
||||
* (GFM; raw HTML is never rendered) as the composer writes it, a single
|
||||
@@ -18,7 +24,7 @@ export default function UserBubble({ text }: { text: string }) {
|
||||
{...{ [QuotableAttribute]: "" }}
|
||||
className="max-w-[80%] min-w-0 gap-2.5! items-stretch rounded-default-lg bg-ink text-on-ink px-4 py-3 text-sm leading-5 break-words"
|
||||
>
|
||||
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]} components={UserMarkdownComponents}>
|
||||
<Markdown remarkPlugins={RemarkPlugins} components={UserMarkdownComponents}>
|
||||
{linkComputerRefs({ text })}
|
||||
</Markdown>
|
||||
</Stack>
|
||||
|
||||
Reference in New Issue
Block a user