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,11 +1,9 @@
import type { PagePropsType } from "@/src/types";
import type { BotTextModel, PagePropsType } from "@/src/types";
import grabBots from "../bots/grab-bots";
import grabActiveBotTask from "../bots/grab-active-bot-task";
import grabBotMessages from "../bots/grab-bot-messages";
import grabBotExtensionsOverview from "../extensions/grab-bot-extensions-overview";
import grabBotComputers from "../computers/grab-bot-computers";
import grabBotHarnessOverview from "../harness/grab-bot-harness-overview";
import grabHarnessOverview from "../harness/grab-harness-overview";
import resolveHarnessCandidates from "@/src/harness/resolve/resolve-harness-candidates";
import grabUserWorkspaces from "../workspaces/grab-user-workspaces";
import ensureBotSession from "./ensure-bot-session";
@@ -25,9 +23,11 @@ type Params = {
* The page props of a conversation (`/admin/bots/<id>` or
* `/admin/workspaces/<id>/bots/<id>`): the rail's crew (the workspace's
* in a workspace) and workspaces, the bot, its active session (marked
* read) and its messages, its models, computers, tools and MCP servers
* and active task there, and whether its mic and read-aloud have a model
* to run on.
* read) and its messages, the text model it runs on, its computers,
* tools and MCP servers and active task there, and whether its mic and
* read-aloud have a model to run on. The full model settings are left to
* the models modal (`api/admin/bots/harness-overview`): they are most of
* a page's weight and only that modal uses them.
* Null when the user can't chat with the bot there.
*/
export default async function grabConversationProps({
@@ -60,21 +60,9 @@ export default async function grabConversationProps({
bot.unread = 0;
/**
* The header's models modal: a worker's own settings, or for the
* coordinator the defaults it runs on (without the providers). In a
* workspace the workspace's own models come first, so what runs now
* shows its pick.
*/
const [bot_messages, bot_harness_overview, bot_computers, bot_task, bot_extensions, transcription, speech] = await Promise.all([
const [bot_messages, bot_text_model, bot_computers, bot_task, bot_extensions, transcription, speech] = await Promise.all([
grabBotMessages({ session_id: session.id!, user_id }),
(bot.kind == "coordinator" && !scope
? grabHarnessOverview().then(({ harnesses }) => ({ harnesses }))
: grabBotHarnessOverview({
bot_id: bot.kind == "coordinator" ? undefined : bot.id,
workspace_id: scope || undefined,
})
).catch(() => null),
grabTextModel({ bot_id: bot.id, workspace_id: scope }),
grabBotComputers({ bot_id: bot.id, workspace_id: scope }),
grabActiveBotTask({ bot_id: bot.id, session_id: session.id }),
grabBotExtensionsOverview({ user_id, bot_id: bot.id, workspace_id: scope }),
@@ -89,7 +77,7 @@ export default async function grabConversationProps({
workspace: conversation.workspace,
bot_session: session,
bot_messages,
bot_harness_overview,
bot_text_model,
bot_computers,
bot_task,
bot_extensions,
@@ -105,3 +93,19 @@ async function hasHarnessModel(params: Parameters<typeof resolveHarnessCandidate
.then((candidates) => candidates.length > 0)
.catch(() => false);
}
/**
* The first text model the bot would try there, if any.
*/
async function grabTextModel(
params: Omit<Parameters<typeof resolveHarnessCandidates>[0], "harness">,
): Promise<BotTextModel | null> {
const [first] = await resolveHarnessCandidates({ ...params, harness: "text" }).catch(() => []);
if (!first) return null;
return {
name: first.model.name || first.model.slug,
provider_name: first.provider.name || first.provider.kind,
};
}
@@ -0,0 +1,33 @@
import type { BotHarnessOverviewPayload } from "@/src/types";
import grabBotHarnessOverview from "./grab-bot-harness-overview";
import grabHarnessOverview from "./grab-harness-overview";
type Params = {
/**
* A bot the caller has checked the session user chats with there.
*/
bot: { id?: number; kind?: string | null };
workspace_id?: number | null;
};
/**
* What a conversation's models modal edits: a worker's own settings, or
* for the coordinator the defaults it runs on (without the providers). In
* a workspace the workspace's own models come first, so what runs now
* shows its pick.
*/
export default async function grabConversationHarnessOverview({
bot,
workspace_id,
}: Params): Promise<BotHarnessOverviewPayload> {
if (bot.kind == "coordinator" && !workspace_id) {
const { harnesses } = await grabHarnessOverview();
return { harnesses };
}
return await grabBotHarnessOverview({
bot_id: bot.kind == "coordinator" ? undefined : bot.id,
workspace_id: workspace_id || undefined,
});
}
+115
View File
@@ -0,0 +1,115 @@
import { brotliCompressSync, constants } from "node:zlib";
import { AppData } from "@/src/data/app-data";
import isCompressibleType from "@/src/utils/is-compressible-type";
import isHashedAssetPath from "@/src/utils/is-hashed-asset-path";
import pickContentEncoding, { type ContentEncoding } from "@/src/utils/pick-content-encoding";
type Params = {
req: Request;
response: Response;
};
/**
* Page scripts and styles, compressed once per path and encoding. Their
* names are their content, so an entry never goes stale.
*/
const HashedAssetCache = new Map<string, Uint8Array<ArrayBuffer>>();
/**
* Compresses a text response for a client that takes it, and caches the
* content-named page scripts and styles for good (bunext gives them an
* hour, with nothing to revalidate against).
*
* Page scripts and styles are compressed whole (brotli or gzip) and kept
* in memory; pages and API responses are gzipped as they stream. Media,
* streams, small bodies and anything already encoded go out as they
* are.
*/
export default async function compressResponse({ req, response }: Params): Promise<Response> {
const { pathname } = new URL(req.url);
const hashed = response.status == 200 && isHashedAssetPath({ pathname });
if (hashed) {
response.headers.set("Cache-Control", "public, max-age=31536000, immutable");
}
if (
!response.body ||
response.status != 200 ||
response.headers.has("Content-Encoding") ||
!isCompressibleType({ content_type: response.headers.get("Content-Type") })
) {
return response;
}
const length = Number(response.headers.get("Content-Length"));
if (length && length < AppData["CompressMinBytes"]) return response;
const accept_encoding = req.headers.get("Accept-Encoding");
const encoding = hashed
? pickContentEncoding({ accept_encoding })
: pickContentEncoding({ accept_encoding, offer: ["gzip"] });
if (!encoding) return response;
const headers = new Headers(response.headers);
headers.set("Content-Encoding", encoding);
headers.delete("Content-Length");
headers.delete("Content-Disposition");
headers.append("Vary", "Accept-Encoding");
if (hashed) {
const body = await compressHashedAsset({ pathname, encoding, response });
return new Response(body, { status: response.status, headers });
}
return new Response(response.body.pipeThrough(new CompressionStream("gzip")), {
status: response.status,
statusText: response.statusText,
headers,
});
}
async function compressHashedAsset({
pathname,
encoding,
response,
}: {
pathname: string;
encoding: ContentEncoding;
response: Response;
}): Promise<Uint8Array<ArrayBuffer>> {
const key = `${encoding}:${pathname}`;
const cached = HashedAssetCache.get(key);
if (cached) {
response.body?.cancel().catch(() => {});
return cached;
}
const bytes = new Uint8Array(await response.arrayBuffer());
const body =
encoding == "br"
? new Uint8Array(
brotliCompressSync(bytes, {
params: {
[constants.BROTLI_PARAM_QUALITY]: AppData["CompressBrotliQuality"],
[constants.BROTLI_PARAM_SIZE_HINT]: bytes.length,
},
}),
)
: Bun.gzipSync(bytes, { level: 9 });
HashedAssetCache.set(key, body);
if (HashedAssetCache.size > AppData["CompressCacheMaxFiles"]) {
const oldest = HashedAssetCache.keys().next().value;
if (oldest) HashedAssetCache.delete(oldest);
}
return body;
}