Make pages load fast on mobile

- Compress responses: page scripts and styles once (brotli or gzip, in
  memory) and cached immutable; pages and API JSON gzipped as they stream
- Service worker serves content-named assets cache-first
- Load the models modal's data when it opens (cached in localStorage)
  instead of shipping the model catalog in every conversation page
- Lazy-load the Tiptap composer and memoize thread messages
- Import lucide icons directly; twui 0.1.4 drops by-name icons from Button
- bun run start builds the pages minified before serving

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-20 13:41:45 +01:00
co-authored by Claude Opus 5.5
parent 3b355d68bc
commit c1a94fc9a5
36 changed files with 840 additions and 147 deletions
@@ -1,4 +1,4 @@
import { useContext } from "react";
import { memo, useContext } from "react";
import Markdown from "react-markdown";
import remarkGfm from "remark-gfm";
import Row from "@moduletrace/twui/layout/Row";
@@ -31,8 +31,11 @@ type Props = {
* 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.
*
* Memoized: a message only renders (and parses its markdown) again when
* it changes, not on every live step of the thread.
*/
export default function BotMessageLine({ message, bot_name, at }: Props) {
function BotMessageLine({ message, bot_name, at }: Props) {
const { onReact } = useContext(BotMessageActionsContext);
const can_speak = Boolean(message.id && message.text?.trim());
@@ -73,3 +76,5 @@ export default function BotMessageLine({ message, bot_name, at }: Props) {
</Stack>
);
}
export default memo(BotMessageLine);
@@ -3,6 +3,7 @@ import Modal from "@moduletrace/twui/elements/Modal";
import H2 from "@moduletrace/twui/layout/H2";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import Loading from "@moduletrace/twui/elements/Loading";
import { CpuAi } from "lucide-react";
import { AppContext } from "@/src/pages/__root";
import type { BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
@@ -11,6 +12,7 @@ import canManageCrew from "@/src/utils/rbac/can-manage-crew";
import isPassThroughUser from "@/src/utils/rbac/is-pass-through-user";
import BotHarnessForm from "@/src/pages/admin/settings/(partials)/bot-harness-form";
import HeaderIconButton from "@/src/components/general/header-icon-button";
import useHarnessOverview from "../(hooks)/use-harness-overview";
type Props = {
bot: BotWithLastMessage;
@@ -18,7 +20,6 @@ type Props = {
* The workspace the conversation is in: the modal edits its models.
*/
workspace?: BUN_SQLITE_COBBLE_WORKSPACES | null;
harnesses?: HarnessOverview[] | null;
onSaved: (harnesses: HarnessOverview[]) => void;
};
@@ -28,12 +29,18 @@ type Props = {
* the coordinator that form is the defaults, for super admins only; a
* worker's (or a workspace's) models are for super admins and admins.
* Everyone else gets the icon disabled, its title saying why.
*
* The models load when the pointer or focus reaches the icon, or it
* opens (`useHarnessOverview`), shown at once from the last time when
* this device has them.
*/
export default function BotModelsModal({ bot, workspace, harnesses, onSaved }: Props) {
export default function BotModelsModal({ bot, workspace, onSaved }: Props) {
const { pageProps } = useContext(AppContext);
const [open, setOpen] = useState(false);
if (!harnesses?.length || !bot.id) return null;
/**
* The pointer or focus reached the icon: start loading before the click.
*/
const [warm, setWarm] = useState(false);
const in_workspace = Boolean(workspace?.id);
const is_coordinator = bot.kind == "coordinator" && !in_workspace;
@@ -42,6 +49,14 @@ export default function BotModelsModal({ bot, workspace, harnesses, onSaved }: P
: canManageCrew({ user_types: pageProps?.user_types });
const name = bot.name || "This bot";
const { harnesses, error, version, setHarnesses } = useHarnessOverview({
bot_id: Number(bot.id),
workspace_id: workspace?.id || null,
enabled: (open || warm) && can_edit,
});
if (!bot.id) return null;
const target = (
<HeaderIconButton
icon={CpuAi}
@@ -65,7 +80,11 @@ export default function BotModelsModal({ bot, workspace, harnesses, onSaved }: P
return (
<Modal
target={target}
targetWrapperProps={{ className: "shrink-0" }}
targetWrapperProps={{
className: "shrink-0",
onPointerEnter: () => setWarm(true),
onFocus: () => setWarm(true),
}}
open={open}
setOpen={setOpen}
className="max-w-3xl bg-paper px-6 pt-6 pb-0"
@@ -86,12 +105,24 @@ export default function BotModelsModal({ bot, workspace, harnesses, onSaved }: P
: `${name} uses the default models unless you pick its own. The text model chats, plans and drives computers; the others read media, transcribe, speak and make images for it.`}
</Span>
</Stack>
<BotHarnessForm
bot={bot}
workspace_id={workspace?.id || null}
harnesses={harnesses}
onSaved={onSaved}
/>
{harnesses?.length ? (
<BotHarnessForm
key={version}
bot={bot}
workspace_id={workspace?.id || null}
harnesses={harnesses}
onSaved={(fresh) => {
setHarnesses(fresh);
onSaved(fresh);
}}
/>
) : 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>
);
}
@@ -0,0 +1,12 @@
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>
);
}
@@ -0,0 +1,43 @@
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,4 +1,5 @@
import { useContext, useEffect, useImperativeHandle, useRef, useState, type Ref } from "react";
import { lazy, Suspense, useContext, useEffect, useImperativeHandle, useRef, useState, type Ref } from "react";
import type { Editor } from "@tiptap/react";
import Row from "@moduletrace/twui/layout/Row";
import Stack from "@moduletrace/twui/layout/Stack";
import P from "@moduletrace/twui/layout/P";
@@ -14,18 +15,25 @@ 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 useComposerEditor from "../(hooks)/use-composer-editor";
import type { ComposerMentionMenu } 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 ComposerEditor from "./composer-editor";
import ComposerInputFallback from "./composer-input-fallback";
import type { ComposerInputApi } from "./composer-input";
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.
*/
@@ -60,7 +68,7 @@ type Props = {
};
/**
* A Tiptap editor (`useComposerEditor`) writing markdown: Enter sends,
* A Tiptap editor (`useComposerEditor`, loaded lazily by `ComposerInput`) 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
@@ -90,17 +98,16 @@ export default function Composer({
const workspaces = useContext(CrewWorkspacesContext);
const send_ref = useRef<() => void>(() => {});
const input = useComposerEditor({
placeholder: `Message ${bot.name}`,
bots,
workspaces,
onChange: ({ markdown }) => setText(markdown),
onSubmit: () => send_ref.current(),
onFiles: files.add,
});
const editor = input.editor;
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}`;
useImperativeHandle(handle_ref, () => ({ insertQuote: input.insertQuote }), [input.insertQuote]);
useImperativeHandle(
handle_ref,
() => ({ insertQuote: ({ text }) => input.current?.insertQuote({ text }) }),
[],
);
/**
* The saved draft goes into the editor once per conversation, after
@@ -115,7 +122,7 @@ export default function Composer({
/** Out of React's render, since mention chips mount as it loads. */
queueMicrotask(() => {
input.setMarkdown({ markdown: text });
input.current?.setMarkdown({ markdown: text });
editor.commands.focus("end");
});
}, [editor, draft.loaded, draft.key]);
@@ -138,7 +145,7 @@ export default function Composer({
return;
}
input.insertText({ text: res.singleRes.text });
input.current?.insertText({ text: res.singleRes.text });
},
});
@@ -171,7 +178,7 @@ export default function Composer({
}
setText("");
input.clear();
input.current?.clear();
files.clear();
onSent({ message: res.singleRes });
}
@@ -204,7 +211,7 @@ export default function Composer({
onCancel={onCancelReply}
/>
) : null}
{input.menu ? <ComposerMentionList menu={input.menu} /> : null}
{menu ? <ComposerMentionList menu={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;
@@ -219,7 +226,21 @@ export default function Composer({
disabled={files.full}
onFiles={files.add}
/>
<ComposerEditor editor={editor} invalid={Boolean(error)} title={error} />
<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>
{recorder.supported ? (
<ComposerVoiceButton
state={recorder.state}
@@ -4,7 +4,7 @@ import Stack from "@moduletrace/twui/layout/Stack";
import H1 from "@moduletrace/twui/layout/H1";
import { CpuAi, Layers, Monitor, Settings } from "lucide-react";
import type { BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
import type { BotWithLastMessage, HarnessOverview } from "@/src/types";
import type { BotWithLastMessage } from "@/src/types";
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";
@@ -26,10 +26,6 @@ type Props = {
*/
workspace?: BUN_SQLITE_COBBLE_WORKSPACES | null;
has_other_unread?: boolean;
/**
* The bot's models (the defaults for the coordinator).
*/
harnesses?: HarnessOverview[] | null;
};
/**
@@ -38,12 +34,10 @@ 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, harnesses: initial }: Props) {
const [harnesses, setHarnesses] = useState(initial);
export default function ConversationHeader({ bot, workspace, has_other_unread }: Props) {
const { computers } = useContext(BotComputersContext);
const { pageProps } = useContext(AppContext);
const model = harnesses?.find((h) => h.harness == "text")?.current;
const [model, setModel] = useState(pageProps?.bot_text_model);
const computer = computers[0];
const can_open_settings =
bot.kind == "coordinator"
@@ -93,8 +87,11 @@ export default function ConversationHeader({ bot, workspace, has_other_unread, h
key={bot.id}
bot={bot}
workspace={workspace}
harnesses={harnesses}
onSaved={setHarnesses}
onSaved={(harnesses) => {
const current = harnesses.find((h) => h.harness == "text")?.current;
setModel(current ? { name: current.name, provider_name: current.provider_name } : null);
}}
/>
</span>
{can_open_settings ? (
@@ -1,4 +1,4 @@
import { useRef, type RefObject } from "react";
import { useMemo, useRef, type RefObject } from "react";
import Stack from "@moduletrace/twui/layout/Stack";
import type {
BotMessageWithAttachments,
@@ -38,7 +38,7 @@ type Props = {
* last one. Text selected in a message can be quoted in the composer.
*/
export default function MessageThread({ bot, messages, progress, reply_progress, has_task, onQuote }: Props) {
const groups = groupMessages({ messages });
const groups = useMemo(() => groupMessages({ messages }), [messages]);
const ref = useRef<HTMLDivElement>(null);
const { selection, clear } = useMessageSelection({ ref });
@@ -1,3 +1,4 @@
import { memo } from "react";
import Stack from "@moduletrace/twui/layout/Stack";
import type { BotMessageWithAttachments } from "@/src/types";
import MessageAttachments from "./message-attachments";
@@ -13,9 +14,10 @@ type Props = {
/**
* One user message: the quoted message it answers, its files, then its
* text (a file-only message has no bubble) and the bot's reactions to it.
* The user doesn't reply to or react to their own messages.
* The user doesn't reply to or react to their own messages. Memoized,
* like `BotMessageLine`.
*/
export default function UserMessage({ message, bot_name }: Props) {
function UserMessage({ message, bot_name }: Props) {
return (
<Stack className="w-full min-w-0 max-w-full items-end gap-1.5!">
{message.reply_to ? (
@@ -36,3 +38,5 @@ export default function UserMessage({ message, bot_name }: Props) {
</Stack>
);
}
export default memo(UserMessage);