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:
2026-10-20 14:37:11 +01:00
parent c1a94fc9a5
commit 4f7e0a6bf0
38 changed files with 1320 additions and 334 deletions
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import composerDraftKey from "../(utils)/composer-draft-key";
type Params = {
@@ -6,17 +6,46 @@ type Params = {
workspace_id?: number | null;
};
/**
* How long typing rests before the draft is written. `localStorage` is
* synchronous, so writing on every keystroke makes a phone's input wait.
*/
const WriteDelayMs = 250;
/**
* The composer's text (markdown), kept in `localStorage` per bot and
* workspace until it's sent, so a reload or leaving the conversation
* doesn't lose it. Read after mount, since the page renders on the server
* first; `loaded` turns true once it has been, for that `key`.
* first; `loaded` turns true once it has been, for that `key`. Writes are
* debounced and flushed on unmount or when the conversation changes.
*/
export default function useComposerDraft({ bot_id, workspace_id }: Params) {
const key = composerDraftKey({ bot_id, workspace_id, part: "text" });
const [draft, setDraft] = useState({ key, text: "", loaded: false });
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
const pending = useRef<{ key: string; text: string } | null>(null);
const flush = () => {
if (timer.current) {
clearTimeout(timer.current);
timer.current = null;
}
const next = pending.current;
pending.current = null;
if (!next) return;
try {
if (next.text) localStorage.setItem(next.key, next.text);
else localStorage.removeItem(next.key);
} catch {}
};
useEffect(() => {
flush();
let text = "";
try {
@@ -26,13 +55,16 @@ export default function useComposerDraft({ bot_id, workspace_id }: Params) {
setDraft({ key, text, loaded: true });
}, [key]);
useEffect(() => () => flush(), []);
const setText = (text: string) => {
setDraft({ key, text, loaded: true });
try {
if (text) localStorage.setItem(key, text);
else localStorage.removeItem(key);
} catch {}
pending.current = { key, text };
if (timer.current) clearTimeout(timer.current);
timer.current = setTimeout(flush, WriteDelayMs);
};
const current = draft.key == key;
@@ -1,13 +1,26 @@
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { AppData } from "@/src/data/app-data";
import type { HarnessOverview } from "@/src/types";
import type {
HarnessCatalog,
HarnessOverview,
HarnessSettingOverview,
} from "@/src/types";
import {
readHarnessCatalogCache,
readHarnessSettingsCache,
writeHarnessCatalogCache,
writeHarnessSettingsCache,
} from "@/src/utils/harness-cache";
import fetchHarnessCatalog from "../(functions)/fetch-harness-catalog";
import fetchHarnessOverview from "../(functions)/fetch-harness-overview";
import {
readHarnessOverviewCache,
writeHarnessOverviewCache,
} from "../(utils)/harness-overview-cache";
harnessCatalogFromOverview,
harnessSettingsFromOverview,
mergeHarnessOverview,
} from "../(utils)/harness-overview-parts";
type Params = {
user_id: number;
bot_id: number;
workspace_id?: number | null;
/**
@@ -18,55 +31,118 @@ type Params = {
};
/**
* The models modal's data for a conversation. Shows what `localStorage`
* last had at once, and fetches it again when there is none or it is
* older than `AppData.HarnessOverviewCacheMs`. `setHarnesses` takes a
* save's fresh data and keeps it.
* The models modal's data for a conversation. The install-wide catalog is
* cached once per user; the conversation's own setting is cached per bot
* and workspace, and is small. Either shows what `localStorage` last had
* at once and is fetched again when it is older than its
* `AppData.Harness*CacheMs`. `setHarnesses` takes a save's fresh data and
* keeps it.
*/
export default function useHarnessOverview({ bot_id, workspace_id, enabled }: Params) {
const [harnesses, setState] = useState<HarnessOverview[]>();
export default function useHarnessOverview({
user_id,
bot_id,
workspace_id,
enabled,
}: Params) {
const [catalog, setCatalog] = useState<HarnessCatalog[]>();
const [settings, setSettings] = useState<HarnessSettingOverview[]>();
const [error, setError] = useState<string>();
/**
* Counts fetched updates, so a form showing the cached data remounts
* with the fresh.
* Counts fetched settings, so a form showing cached data remounts with
* the fresh when it isn't being edited.
*/
const [version, setVersion] = useState(0);
useEffect(() => {
if (!enabled) return;
if (!enabled || !user_id || !bot_id) return;
let cancelled = false;
const cached = readHarnessOverviewCache({ bot_id, workspace_id });
const cached_catalog = readHarnessCatalogCache({ user_id });
const cached_settings = readHarnessSettingsCache({ user_id, bot_id, workspace_id });
if (cached) setState(cached.harnesses);
if (cached && Date.now() - cached.at < AppData.HarnessOverviewCacheMs) return;
if (cached_catalog) setCatalog(cached_catalog.data.harnesses);
if (cached_settings) setSettings(cached_settings.data.harnesses);
const now = Date.now();
const stale = (at: number | undefined, max_age_ms: number) =>
!at || now - at >= max_age_ms;
const need_catalog = stale(cached_catalog?.at, AppData.HarnessCatalogCacheMs);
const need_settings = stale(cached_settings?.at, AppData.HarnessOverviewCacheMs);
if (!need_catalog && !need_settings) return;
setError(undefined);
fetchHarnessOverview({ bot_id, workspace_id })
.then((res) => {
const loadCatalog = async () => {
try {
const res = await fetchHarnessCatalog();
if (cancelled) return;
if (res.success && res.singleRes) {
setState(res.singleRes.harnesses);
setVersion((prev) => prev + 1);
writeHarnessOverviewCache({ bot_id, workspace_id, harnesses: res.singleRes.harnesses });
} else if (!cached) {
setCatalog(res.singleRes.harnesses);
writeHarnessCatalogCache({ user_id, data: res.singleRes });
} else if (!cached_catalog) {
setError(res.msg || "Couldn't load the models.");
}
})
.catch(() => {
if (!cancelled && !cached) setError("Couldn't load the models.");
});
} catch {
if (!cancelled && !cached_catalog) setError("Couldn't load the models.");
}
};
const loadSettings = async () => {
try {
const res = await fetchHarnessOverview({ bot_id, workspace_id });
if (cancelled) return;
if (res.success && res.singleRes) {
setSettings(res.singleRes.harnesses);
setVersion((prev) => prev + 1);
writeHarnessSettingsCache({
user_id,
bot_id,
workspace_id,
data: res.singleRes,
});
} else if (!cached_settings) {
setError(res.msg || "Couldn't load the models.");
}
} catch {
if (!cancelled && !cached_settings) setError("Couldn't load the models.");
}
};
void Promise.all([need_catalog ? loadCatalog() : null, need_settings ? loadSettings() : null]);
return () => {
cancelled = true;
};
}, [enabled, bot_id, workspace_id]);
}, [enabled, user_id, bot_id, workspace_id]);
const harnesses = useMemo(
() => mergeHarnessOverview({ catalog, settings }),
[catalog, settings],
);
/**
* A save answers with the full overview: keep both parts of the cache
* current, so the next open is instant and the catalog is not fetched
* again.
*/
const setHarnesses = (fresh: HarnessOverview[]) => {
setState(fresh);
writeHarnessOverviewCache({ bot_id, workspace_id, harnesses: fresh });
const next_catalog = harnessCatalogFromOverview({ harnesses: fresh });
const next_settings = harnessSettingsFromOverview({ harnesses: fresh });
setCatalog(next_catalog);
setSettings(next_settings);
writeHarnessCatalogCache({ user_id, data: { harnesses: next_catalog } });
writeHarnessSettingsCache({
user_id,
bot_id,
workspace_id,
data: { harnesses: next_settings },
});
};
return { harnesses, error, version, setHarnesses };
@@ -48,14 +48,29 @@ export default function useMessageSelection({ ref }: Params) {
setSelection({ text, rect: range.getBoundingClientRect() });
};
/**
* Reading a selection on every scroll event is costly on a phone;
* one read per frame is enough.
*/
let frame = 0;
const onScroll = () => {
if (frame) return;
frame = requestAnimationFrame(() => {
frame = 0;
read();
});
};
document.addEventListener("selectionchange", read);
ref.current?.addEventListener("scroll", read, { passive: true });
ref.current?.addEventListener("scroll", onScroll, { passive: true });
const scroller = ref.current;
return () => {
document.removeEventListener("selectionchange", read);
scroller?.removeEventListener("scroll", read);
scroller?.removeEventListener("scroll", onScroll);
if (frame) cancelAnimationFrame(frame);
};
}, [ref]);