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:
@@ -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]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user