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:
@@ -0,0 +1,18 @@
|
||||
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
|
||||
import type { APIResponseObject } from "@moduletrace/bunext/types";
|
||||
import type { BotConversationRequest } from "@/src/schemas/bot-schema";
|
||||
import type { BotHarnessOverviewPayload } from "@/src/types";
|
||||
|
||||
/**
|
||||
* What a conversation's models modal edits: per harness, the setting, the
|
||||
* models it can pick and what it runs on now.
|
||||
*/
|
||||
export default async function fetchHarnessOverview({ bot_id, workspace_id }: BotConversationRequest) {
|
||||
return await fetchApi<
|
||||
BotConversationRequest,
|
||||
APIResponseObject<BotHarnessOverviewPayload>
|
||||
>(`/api/admin/bots/harness-overview`, {
|
||||
method: "POST",
|
||||
body: { bot_id, workspace_id },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import type { HarnessOverview } from "@/src/types";
|
||||
import fetchHarnessOverview from "../(functions)/fetch-harness-overview";
|
||||
import {
|
||||
readHarnessOverviewCache,
|
||||
writeHarnessOverviewCache,
|
||||
} from "../(utils)/harness-overview-cache";
|
||||
|
||||
type Params = {
|
||||
bot_id: number;
|
||||
workspace_id?: number | null;
|
||||
/**
|
||||
* Loads once this turns true (the modal is about to open), not with
|
||||
* the page.
|
||||
*/
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export default function useHarnessOverview({ bot_id, workspace_id, enabled }: Params) {
|
||||
const [harnesses, setState] = useState<HarnessOverview[]>();
|
||||
const [error, setError] = useState<string>();
|
||||
/**
|
||||
* Counts fetched updates, so a form showing the cached data remounts
|
||||
* with the fresh.
|
||||
*/
|
||||
const [version, setVersion] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
|
||||
let cancelled = false;
|
||||
const cached = readHarnessOverviewCache({ bot_id, workspace_id });
|
||||
|
||||
if (cached) setState(cached.harnesses);
|
||||
if (cached && Date.now() - cached.at < AppData.HarnessOverviewCacheMs) return;
|
||||
|
||||
setError(undefined);
|
||||
|
||||
fetchHarnessOverview({ bot_id, workspace_id })
|
||||
.then((res) => {
|
||||
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) {
|
||||
setError(res.msg || "Couldn't load the models.");
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled && !cached) setError("Couldn't load the models.");
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [enabled, bot_id, workspace_id]);
|
||||
|
||||
const setHarnesses = (fresh: HarnessOverview[]) => {
|
||||
setState(fresh);
|
||||
writeHarnessOverviewCache({ bot_id, workspace_id, harnesses: fresh });
|
||||
};
|
||||
|
||||
return { harnesses, error, version, setHarnesses };
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useContext, useEffect, useRef, useState } from "react";
|
||||
import { useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import type { BotMessageReactionEmoji } from "@/src/dict/bot-dict";
|
||||
import type {
|
||||
@@ -162,6 +162,18 @@ export default function Conversation() {
|
||||
if (res.success && res.singleRes) applyReactions(res.singleRes);
|
||||
};
|
||||
|
||||
/**
|
||||
* Kept the same across renders, so a live step or a face change
|
||||
* doesn't re-render every message (they read it from context).
|
||||
*/
|
||||
const message_actions = useMemo(
|
||||
() => ({
|
||||
onReply: ({ message }: { message: BotMessageWithAttachments }) => setReplyTo(message),
|
||||
onReact: reactToMessage,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
if (!bot?.id || !live_bot) return null;
|
||||
|
||||
return (
|
||||
@@ -169,18 +181,12 @@ export default function Conversation() {
|
||||
<CrewWorkspacesContext.Provider value={workspaces}>
|
||||
<BotComputersContext.Provider value={bot_computers}>
|
||||
<SpeakMessageContext.Provider value={speak}>
|
||||
<BotMessageActionsContext.Provider
|
||||
value={{
|
||||
onReply: ({ message }) => setReplyTo(message),
|
||||
onReact: reactToMessage,
|
||||
}}
|
||||
>
|
||||
<BotMessageActionsContext.Provider value={message_actions}>
|
||||
<Stack className="grow min-w-0 h-full gap-0! items-stretch">
|
||||
<ConversationHeader
|
||||
bot={live_bot}
|
||||
workspace={workspace}
|
||||
has_other_unread={has_other_unread}
|
||||
harnesses={pageProps?.bot_harness_overview?.harnesses}
|
||||
/>
|
||||
<MessageThread
|
||||
bot={live_bot}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import type { HarnessOverview } from "@/src/types";
|
||||
|
||||
/**
|
||||
* Bumped when `HarnessOverview` changes shape, so an older cache is
|
||||
* fetched again instead of read.
|
||||
*/
|
||||
const CacheVersion = 1;
|
||||
|
||||
type Key = {
|
||||
bot_id: number;
|
||||
workspace_id?: number | null;
|
||||
};
|
||||
|
||||
export type CachedHarnessOverview = {
|
||||
v: number;
|
||||
/**
|
||||
* Unix ms it was fetched.
|
||||
*/
|
||||
at: number;
|
||||
harnesses: HarnessOverview[];
|
||||
};
|
||||
|
||||
function cacheKey({ bot_id, workspace_id }: Key) {
|
||||
return `${AppData.HarnessOverviewStorageKey}:${bot_id}:${workspace_id || 0}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The models modal's data last fetched for this conversation, from
|
||||
* `localStorage`; null when there is none or it can't be read.
|
||||
*/
|
||||
export function readHarnessOverviewCache(key: Key): CachedHarnessOverview | null {
|
||||
try {
|
||||
const cached = JSON.parse(localStorage.getItem(cacheKey(key)) || "null");
|
||||
|
||||
return cached?.v == CacheVersion &&
|
||||
Array.isArray(cached.harnesses) &&
|
||||
typeof cached.at == "number"
|
||||
? cached
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeHarnessOverviewCache({ harnesses, ...key }: Key & { harnesses: HarnessOverview[] }) {
|
||||
try {
|
||||
localStorage.setItem(cacheKey(key), JSON.stringify({ v: CacheVersion, at: Date.now(), harnesses }));
|
||||
} catch {}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import Row from "@moduletrace/twui/layout/Row";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import Checkbox from "@moduletrace/twui/form/Checkbox";
|
||||
import LucideIcon from "@moduletrace/twui/elements/lucide-icon";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import dayjs from "dayjs";
|
||||
import type { TWUIButtonColorType } from "@moduletrace/twui/layout/Button";
|
||||
import {
|
||||
@@ -79,7 +79,7 @@ export default function LogCard({
|
||||
</Stack>
|
||||
</Row>
|
||||
<DeleteLogsButton ids={[log_id]} afterDelete={afterDelete}>
|
||||
<LucideIcon name="Trash2" size={16} />
|
||||
<Trash2 size={16} />
|
||||
</DeleteLogsButton>
|
||||
</Row>
|
||||
{log.plain_text ? (
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { BUN_SQLITE_COBBLE_LOGS_JOIN } from "@/src/types/sql-joins";
|
||||
import Modal from "@moduletrace/twui/elements/Modal";
|
||||
import Paper from "@moduletrace/twui/elements/Paper";
|
||||
import Tag from "@moduletrace/twui/elements/Tag";
|
||||
import LucideIcon from "@moduletrace/twui/elements/lucide-icon";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import H3 from "@moduletrace/twui/layout/H3";
|
||||
import Link from "@moduletrace/twui/layout/Link";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
@@ -115,7 +115,7 @@ export default function LogDetailsModal({
|
||||
size="small"
|
||||
>
|
||||
<Row className="gap-2! flex-nowrap">
|
||||
<LucideIcon name="Trash2" size={15} />
|
||||
<Trash2 size={15} />
|
||||
<Span>Delete log</Span>
|
||||
</Row>
|
||||
</DeleteLogsButton>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import Tag from "@moduletrace/twui/elements/Tag";
|
||||
import LucideIcon from "@moduletrace/twui/elements/lucide-icon";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import Checkbox from "@moduletrace/twui/form/Checkbox";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import type { BUN_SQLITE_COBBLE_LOGS_JOIN } from "@/src/types/sql-joins";
|
||||
@@ -100,7 +100,7 @@ export default function LogTableRow({
|
||||
afterDelete={afterDelete}
|
||||
size="small"
|
||||
>
|
||||
<LucideIcon name="Trash2" size={15} />
|
||||
<Trash2 size={15} />
|
||||
</DeleteLogsButton>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import Paper from "@moduletrace/twui/elements/Paper";
|
||||
import LucideIcon from "@moduletrace/twui/elements/lucide-icon";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
@@ -42,7 +42,7 @@ export default function LogsToolbar({ init }: Props) {
|
||||
size="small"
|
||||
>
|
||||
<Row className="gap-2! flex-nowrap">
|
||||
<LucideIcon name="Trash2" size={15} />
|
||||
<Trash2 size={15} />
|
||||
<Span>Delete selected ({selected_ids.length})</Span>
|
||||
</Row>
|
||||
</DeleteLogsButton>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { DatabaseBackup } from "lucide-react";
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import Tag from "@moduletrace/twui/elements/Tag";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
@@ -30,7 +31,7 @@ export default function CreateBackupButton() {
|
||||
{status?.error && <Tag color="error">{status.msg}</Tag>}
|
||||
<Button
|
||||
title="Create Backup"
|
||||
beforeIcon="DatabaseBackup"
|
||||
beforeIcon={<DatabaseBackup />}
|
||||
loading={loading}
|
||||
onClick={() => {
|
||||
create_backup();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Upload } from "lucide-react";
|
||||
import FileUpload from "@moduletrace/twui/form/FileUpload";
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import Tag from "@moduletrace/twui/elements/Tag";
|
||||
@@ -47,7 +48,7 @@ export default function UploadRestoreForm() {
|
||||
<Button
|
||||
title="Upload And Restore Backup"
|
||||
color="warning"
|
||||
beforeIcon="Upload"
|
||||
beforeIcon={<Upload />}
|
||||
loading={loading}
|
||||
disabled={!file || loading}
|
||||
onClick={() => {
|
||||
|
||||
Reference in New Issue
Block a user