diff --git a/AGENTS.md b/AGENTS.md index 4cc51f8..e2777fc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,6 +17,7 @@ This is **Cobble** (`cobble.moduletrace.org`): an open-source crew of small AI w `src/server.ts` - turns on SQLite foreign keys (`enableForeignKeys`, so the schema's cascades fire; off by default per connection), runs `bunextInit()`, starts `Bun.serve`, then starts cron: - `/media/*` short-circuits to the media server handler +- bunext's responses go through `compressResponse` (`src/functions/server/compress-response.ts`): page scripts and styles bunext names after their content (`isHashedAssetPath`) are cached `immutable` and compressed once (brotli or gzip, kept in memory); pages and API JSON are gzipped as they stream; media, streams and small bodies go out as they are. `public/sw.js` serves those same assets cache-first. Keep page props small: every page is a full server render, so anything only a modal needs loads when the modal opens (the chat header's models modal fetches `api/admin/bots/harness-overview` and keeps it in `localStorage` for `AppData.HarnessOverviewCacheMs`). Never render a lucide icon by name (twui's `LucideIcon`, a string `beforeIcon`): that ships every icon; import the icon - `/ws` upgrades only after `userAuth` succeeds (session frozen into the socket at upgrade time); the `websocket` handler object lives in `src/websocket/` - everything else goes to `bunext.bunextRequestHandler` - `ensureDockerNetwork()` makes sure the computers' docker network (`AppData.DockerNetworkName`) exists before the server starts; without docker the process exits (computers are containers, so nothing works without it). Creating a computer's container checks it again first @@ -27,7 +28,7 @@ This is **Cobble** (`cobble.moduletrace.org`): an open-source crew of small AI w | Purpose | Command | | -------------------------------- | --------------------------------------------------------- | | Dev server | `bun run dev` (runs `bun src/server.ts`) | -| Production | `bun run start` (`MAIN_SERVER=1 NODE_ENV=production bun src/server.ts`) | +| Production | `bun run start` (builds the pages minified with `bunext build`, then `MAIN_SERVER=1 NODE_ENV=production bun src/server.ts`; bunext's start alone reuses whatever `.bunext/` holds, a dev build included) | | Type-check only | `bun run typecheck` | | Run tests | `bun run test` | | Regenerate favicon/brand/PWA assets | `bun run gen:favicon` (runs `src/scripts/generate-brand-assets.ts`) | @@ -91,7 +92,7 @@ Inspect live data with `bun llm-code-assistant/query-db.ts ""` - a read-onl - **Coordinator:** the install has exactly one `kind: coordinator` bot, the super admin's alone, that coordinates the crew and delegates to every worker. It is created at signup (`bun run seed`) and, as a fallback, by the super admin's `/admin/bots`, all via `ensureCoordinatorBot` (`src/functions/backend/db/bots/ensure-coordinator-bot.ts`, idempotent). Name in `CoordinatorBotName` (`src/dict/bot-dict.ts`). Its default prompt is `CoordinatorSystemPrompt` (`src/data/coordinator-system-prompt.ts`): attached at creation, and backfilled onto a coordinator whose prompt is empty, never over an edited one. - **Routes:** `/admin/bots` ensures the coordinator and redirects the super admin to its chat (`/admin/bots/1` on a fresh install), anyone else to their latest bot, else their first workspace, else an empty page; `/admin/bots/[bot_id]` renders the conversation (header, thread and composer, the last two held to 760px; there is no right panel); `/admin/bots/[bot_id]/models` is a worker bot's own model settings, for super admins and admins (the coordinator's redirects to its chat); `/admin/workspaces/[workspace_id]` is a workspace's dashboard (`(sections)/workspace-dashboard.tsx`: read-only picture, name, description and context, latest activity, bots with their per-workspace instructions and memory, computers; its header links into `/admin/settings/workspaces/[workspace_id]`, whose `workspace-overview.tsx` has the editable groups) and `/admin/workspaces/[workspace_id]/bots/[bot_id]` is the same `Conversation` section scoped to the workspace (`pageProps.workspace`; only the coordinator and member bots). All render in the full-bleed `src/layouts/bots/` layout (rail of conversations + user popover), which `__root.tsx` uses for every `/admin/*` page. - **Server:** `src/functions/backend/db/bots/` (`grab-bots`, `grab-bot-messages`, `grab-active-bot-task`, `grab-user-bot`, `create-bot`, `insert-user-message`). `api/admin/bots/send-message.ts` posts a user message to a bot the session user chats with (zod: `src/schemas/bot-schema.ts`), queues the bot's reply (`enqueueBotReply`) and wakes the queue (`src/bots-queue/wake-bots-queue.ts`; never import `src/bots-queue/index.ts` from a page or API route, it pulls the bunext runtime into the page bundles). `upload-attachment.ts` stores one file for the next message (private `media` row, paradigm `bot-attachment`, type from `attachmentFileKind`, documents get `text_content`, limits in `AppData.BotAttachment*`) and `send-message` takes their `media_ids` (checked to be the user's own by `grabAttachableMedia`), linked in `bot_message_media`; messages carry `attachments` (`BotMessageWithAttachments`). `mark-read.ts` clears a session's unread when a message arrives in the open conversation; `session-messages.ts` returns a session's latest messages, which the open conversation polls every `AppData.BotReplyCatchUpMs` while a reply is due (`use-reply-catch-up`), so a reply whose live push was missed (socket down) still lands without a reload (replayed as a `server:bot-message`, so the rail and unread take it too); `create-bot.ts` adds a worker (and puts it in a workspace when given one); `save-harness-settings.ts` saves a worker bot's models. -- **Composer:** a Tiptap editor (`(hooks)/use-composer-editor.ts`, `(partials)/composer-editor.tsx`), not a textarea, that reads and writes markdown (`@tiptap/markdown`), so drafts (`useComposerDraft`) and sent messages are markdown. Bold, italic, strike, code, lists, quotes and links (no headings), from shortcuts, markdown typing or the selection bubble (`composer-format-bubble.tsx`, items in `(data)/composer-formats.ts`). `@` mentions a bot and `#` a workspace (`(data)/composer-mention-kinds.ts`, menu `composer-mention-list.tsx` above the input, items from `CrewBotsContext` and `CrewWorkspacesContext` via `searchMentionItems`), shown as chips (`composer-mention-chip.tsx`) and written as `[bot:ID]` / `[workspace:ID]` (`(utils)/mention-markdown.ts`, `src/utils/workspace-ref.ts`). Enter sends, Shift+Enter breaks the line; in a list or code block Enter is the editor's and Mod+Enter sends. Selecting text in a message (`data-quotable`, `use-message-selection`) shows a Quote button (`quote-selection-button.tsx`) that inserts it as a blockquote at the cursor (`ComposerHandle.insertQuote`). Before a reply, `grabMessageRefNames` names the refs in the user's messages that the user reaches, and `botMessagesToHarness` adds a `[Mentions: ...]` line after each one (`describeMessageRefs`). `@tiptap/react` needs `use-sync-external-store`, which is CommonJS and calls `require("react")`; bunext keeps React external in page bundles, so the app installs an ESM stand-in from `packages/use-sync-external-store-esm` (a `file:` dependency plus an override; the Dockerfile copies `packages/` before `bun install`). +- **Composer:** a Tiptap editor (`(hooks)/use-composer-editor.ts`, `(partials)/composer-editor.tsx`), not a textarea, loaded lazily after the page by `(partials)/composer-input.tsx` (Tiptap is the page's heaviest code; `composer-input-fallback.tsx` holds its place, and the composer reaches its commands through a ref, so nothing else may import Tiptap statically), that reads and writes markdown (`@tiptap/markdown`), so drafts (`useComposerDraft`) and sent messages are markdown. Bold, italic, strike, code, lists, quotes and links (no headings), from shortcuts, markdown typing or the selection bubble (`composer-format-bubble.tsx`, items in `(data)/composer-formats.ts`). `@` mentions a bot and `#` a workspace (`(data)/composer-mention-kinds.ts`, menu `composer-mention-list.tsx` above the input, items from `CrewBotsContext` and `CrewWorkspacesContext` via `searchMentionItems`), shown as chips (`composer-mention-chip.tsx`) and written as `[bot:ID]` / `[workspace:ID]` (`(utils)/mention-markdown.ts`, `src/utils/workspace-ref.ts`). Enter sends, Shift+Enter breaks the line; in a list or code block Enter is the editor's and Mod+Enter sends. Selecting text in a message (`data-quotable`, `use-message-selection`) shows a Quote button (`quote-selection-button.tsx`) that inserts it as a blockquote at the cursor (`ComposerHandle.insertQuote`). Before a reply, `grabMessageRefNames` names the refs in the user's messages that the user reaches, and `botMessagesToHarness` adds a `[Mentions: ...]` line after each one (`describeMessageRefs`). `@tiptap/react` needs `use-sync-external-store`, which is CommonJS and calls `require("react")`; bunext keeps React external in page bundles, so the app installs an ESM stand-in from `packages/use-sync-external-store-esm` (a `file:` dependency plus an override; the Dockerfile copies `packages/` before `bun install`). - **Message rendering:** bot messages are markdown, rendered with `react-markdown` + `remark-gfm` (raw HTML never rendered) through the twui-based map in `[bot_id]/(data)/message-markdown-components.tsx` (`markdown-link`, `markdown-list`, `markdown-code-block` partials). A bot names a crew member as `[bot:ID]` (`src/utils/bot-ref.ts`, linked to `#bot-ID` by `link-computer-refs.ts`): `(partials)/bot-ref-chip.tsx` shows its avatar and name, linked to its conversation (in the open workspace when it's in the crew there), from the live crew (`CrewBotsContext`), so a bot created in that reply links too; the coordinator is told to write them (`CoordinatorReplyNote`), and `find_bot` and `create_bot` return them. A workspace is named as `[workspace:ID]` (`#workspace-ID`, `(partials)/workspace-ref-chip.tsx`, from `CrewWorkspacesContext`). User messages are markdown too, rendered in the ink bubble by `user-bubble.tsx` with `UserMarkdownComponents` and `remark-breaks` (a single line break stays one, so older plain messages read as typed). - **Replies and reactions:** every bot message has a row under it (`(partials)/message-actions.tsx`, handlers from `BotMessageActionsContext`) to reply or react; the user's own messages only show the bot's reactions, read-only. Replying quotes the message above the composer (`composer-reply-preview.tsx`) and the sent one carries `bot_messages.reply_to_message_id` (not a foreign key: a deleted quote only drops it); `send-message` takes any message of the same conversation (the user's, same bot and workspace, any session, so a rollover or a moved-on thread still sends), and the conversation drops a picked quote when its session changes. Messages carry `reply_to` (`grabMessagesReplies`, also on relayed messages), shown by `message-reply-quote.tsx`, and the bot reads it as `[Replying to ...]` before the user's turn (`botMessagesToHarness`). Reactions are a fixed set (`BotMessageReactions` in `src/dict/bot-dict.ts`) in `bot_message_reactions` (one row per message, emoji and user, or bot); `api/admin/bots/react-message.ts` toggles the user's own on a bot message in their sessions (`grabUserBotMessage`, `toggleMessageReaction`), and every bot gets `react_to_message` (`src/harness/tools/bot/react-to-message-tool.ts`, chat replies and tasks: a message in the session it answers, the user's latest by default). Both push the message's fresh reactions (`grabMessagesReactions`, `mine` for the session user) to the session's user as `server:bot-reaction` (`BotMessageReactionData`), which replaces the message's own; `grabBotMessages` adds them when given `user_id`. Reactions aren't relayed between processes (production runs one). - **Attachments and thinking:** the composer uploads files as they're picked, pasted or dropped (`use-composer-attachments`), shows them as chips, and sends once none is still uploading; the thread shows images as thumbnails and other files as chips. Replies read the files of the last `BotReplyAttachmentMessages` messages with any (`load-attachment-parts.ts`: images and audio as parts, documents as their text up to `BotAttachmentMaxTextChars`, videos as a note); older ones are only named. Tasks don't get the files yet. While the user has the last word in a session (every user message queues a reply, so this needs no face and shows even while the bot works in another session) or the bot waits for a model (`BotWaitingStatusLine`) or a task for its computer (`BotComputerBusyStatusLine`), `BotThinkingRow` sits under the last message; while a task runs it reads " is working…" with the task's latest step, and a reply working through tools shows its latest step under "is thinking…" (`use-reply-progress`). A coordinator with no workers gets `CoordinatorSoloNote` instead of `CoordinatorReplyNote`, so it never claims to have handed work to a worker. Live notices come from the process that ran the job at once, and from every other process through the live relay. diff --git a/bun.lock b/bun.lock index ff2697a..7ba238c 100644 --- a/bun.lock +++ b/bun.lock @@ -9,7 +9,7 @@ "@modelcontextprotocol/sdk": "^1.32.1", "@moduletrace/bun-sqlite": "^1.3.0", "@moduletrace/bunext": "^1.3.3", - "@moduletrace/twui": "^0.1.2", + "@moduletrace/twui": "^0.1.4", "@tiptap/extension-bubble-menu": "3.32.0", "@tiptap/extension-mention": "3.32.0", "@tiptap/extension-placeholder": "3.32.0", @@ -255,7 +255,7 @@ "@moduletrace/bunext": ["@moduletrace/bunext@1.3.3", "https://git.tben.me/api/packages/Moduletrace/npm/%40moduletrace%2Fbunext/-/1.3.3/bunext-1.3.3.tgz", { "dependencies": { "@tailwindcss/postcss": "^4.3.3", "@types/bun": "1.3.14", "@types/node": "^24.10.0", "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", "bun-plugin-tailwind": "^0.1.2", "chalk": "^5.6.2", "chokidar": "^5.0.0", "commander": "^14.0.2", "esbuild": "^0.28.2", "lightningcss-wasm": "^1.32.0", "lodash": "^4.18.1", "ora": "^9.0.0", "postcss": "^8.5.30", "react": "^19.3.0", "react-dom": "^19.3.0", "tailwindcss": "^4.3.3", "typescript": "^5.0.0", "uuid": "^14.0.2" }, "bin": { "bunext": "dist/commands/index.js" } }, "sha512-bbUtQaigsDPUQf6vtDOSCo8GYR7Gnw2ny1Fi3JtUnKsYsYQEcEadzmqntAgf7rLcKc12dadDUgFLxru+jiA92g=="], - "@moduletrace/twui": ["@moduletrace/twui@0.1.2", "https://git.tben.me/api/packages/Moduletrace/npm/%40moduletrace%2Ftwui/-/0.1.2/twui-0.1.2.tgz", { "dependencies": { "lodash": "^4.18.1", "tailwind-merge": "^3.7.0" }, "peerDependencies": { "ace-builds": "^1.40.0", "lucide-react": ">=0.577.0", "next-mdx-remote": "^6.0.0", "openai": ">=6.0.0", "react": ">=18", "react-dom": ">=18", "react-markdown": ">=9.0.0", "rehype-prism-plus": "^2.0.0", "remark-gfm": "^4.0.0", "tailwindcss": "^4" }, "optionalPeers": ["ace-builds", "next-mdx-remote", "openai", "react-markdown", "rehype-prism-plus", "remark-gfm"] }, "sha512-90j6jKyMWlgPAENTKW89Yj74tEA9cXXHQYKmxZKqL0T9gK/tYCSM+Rd/mo+1p3vJmNwTOHLViksL7+0v5Wk7Zg=="], + "@moduletrace/twui": ["@moduletrace/twui@0.1.4", "https://git.tben.me/api/packages/Moduletrace/npm/%40moduletrace%2Ftwui/-/0.1.4/twui-0.1.4.tgz", { "dependencies": { "lodash": "^4.18.1", "tailwind-merge": "^3.7.0" }, "peerDependencies": { "ace-builds": "^1.40.0", "lucide-react": ">=0.577.0", "next-mdx-remote": "^6.0.0", "openai": ">=6.0.0", "react": ">=18", "react-dom": ">=18", "react-markdown": ">=9.0.0", "rehype-prism-plus": "^2.0.0", "remark-gfm": "^4.0.0", "tailwindcss": "^4" }, "optionalPeers": ["ace-builds", "next-mdx-remote", "openai", "react-markdown", "rehype-prism-plus", "remark-gfm"] }, "sha512-yPcr86nGd7CYYcEZYcm0IxMSkcP8U2vYFwqQyBr9UAHiG5JVxJauPDfxidCu740Abwbj2Oq+njjKoEP98SoM7A=="], "@next/mdx": ["@next/mdx@16.3.0", "", { "dependencies": { "source-map": "^0.7.0" }, "peerDependencies": { "@mdx-js/loader": ">=0.15.0", "@mdx-js/react": ">=0.15.0" }, "optionalPeers": ["@mdx-js/loader", "@mdx-js/react"] }, "sha512-Vpfuf928ACvx4AOdDXWx/cj13Ak6xlvMn3QCkjvfQMmLnQWCesQTeY01CPDbIyZvjY+NOwP7bvbvJ2ghkj/3GA=="], diff --git a/package.json b/package.json index c073628..7fc9188 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "db:backup": "bunx bun-sqlite backup", "seed": "bun src/scripts/seed-super-admin.tsx", "seed:bots": "bun src/scripts/seed-bots.ts", - "start": "MAIN_SERVER=1 NODE_ENV=production bun src/server.ts", + "start": "NODE_ENV=production bunx bunext build && MAIN_SERVER=1 NODE_ENV=production bun src/server.ts", "typecheck": "tsc --noEmit", "test": "bun test" }, @@ -41,7 +41,7 @@ "@modelcontextprotocol/sdk": "^1.32.1", "@moduletrace/bun-sqlite": "^1.3.0", "@moduletrace/bunext": "^1.3.3", - "@moduletrace/twui": "^0.1.2", + "@moduletrace/twui": "^0.1.4", "@tiptap/extension-bubble-menu": "3.32.0", "@tiptap/extension-mention": "3.32.0", "@tiptap/extension-placeholder": "3.32.0", diff --git a/public/sw.js b/public/sw.js index a594a81..6f4b11d 100644 --- a/public/sw.js +++ b/public/sw.js @@ -9,7 +9,7 @@ * Bump `CACHE_VERSION` when this file changes in a way that must drop the * old caches. */ -const CACHE_VERSION = "cobble-v1"; +const CACHE_VERSION = "cobble-v2"; const CACHE_NAME = `cobble-shell-${CACHE_VERSION}`; const OFFLINE_URL = "/offline.html"; @@ -39,6 +39,16 @@ const ASSET_PATHS = ["/favicon.ico", "/favicon.png", "/apple-touch-icon.png"]; const BYPASS_PREFIXES = ["/api/", "/ws", "/media", "/computers/"]; const BYPASS_PATHS = ["/sw.js", "/manifest.webmanifest", "/auth/logout"]; +/** + * Page scripts and styles bunext names after their content: one at such + * a path never changes, so it is served from the cache without asking the + * network again (`src/utils/is-hashed-asset-path.ts` has the same + * pattern). Each deploy adds new names, so the oldest are dropped past + * `MAX_HASHED_ASSETS`. + */ +const HASHED_ASSET = /^\/\.bunext\/public\/pages\/(?:.*\/)?(?:chunk-)?[A-Z0-9]{8}\.(?:js|css)$/; +const MAX_HASHED_ASSETS = 400; + const DEFAULT_ICON = "/icons/icon-192.png"; const DEFAULT_BADGE = "/icons/icon-192.png"; @@ -105,6 +115,11 @@ self.addEventListener("fetch", (event) => { if (!isAssetRequest(url)) return; + if (HASHED_ASSET.test(url.pathname)) { + event.respondWith(cacheFirst(request)); + return; + } + event.respondWith( caches.open(CACHE_NAME).then(async (cache) => { const cached = await cache.match(request); @@ -123,6 +138,38 @@ self.addEventListener("fetch", (event) => { ); }); +async function cacheFirst(request) { + const cache = await caches.open(CACHE_NAME); + const cached = await cache.match(request); + + if (cached) return cached; + + const response = await fetch(request); + + if (response.ok && response.type === "basic") { + await cache.put(request, response.clone()).catch(() => undefined); + trimHashedAssets(cache).catch(() => undefined); + } + + return response; +} + +/** + * Cache keys come back in insertion order, so the first hashed ones are + * the oldest. + */ +async function trimHashedAssets(cache) { + const hashed = (await cache.keys()).filter((request) => + HASHED_ASSET.test(new URL(request.url).pathname), + ); + + await Promise.all( + hashed + .slice(0, Math.max(0, hashed.length - MAX_HASHED_ASSETS)) + .map((request) => cache.delete(request)), + ); +} + /** * The page asks for a new version from its update notice. Nothing skips * the wait until the user says so. diff --git a/src/components/general/tagline-section.tsx b/src/components/general/tagline-section.tsx index 0ee852a..6269761 100755 --- a/src/components/general/tagline-section.tsx +++ b/src/components/general/tagline-section.tsx @@ -1,53 +1,46 @@ -import Section from "@moduletrace/twui/layout/Section"; -import Container from "@moduletrace/twui/layout/Container"; -import H2 from "@moduletrace/twui/layout/H2"; -import Stack from "@moduletrace/twui/layout/Stack"; -import type { TWUILucideIconName } from "@moduletrace/twui/elements/lucide-icon"; -import LucideIcon from "@moduletrace/twui/elements/lucide-icon"; -import Span from "@moduletrace/twui/layout/Span"; -import type { ReactNode } from "react"; - -type Props = { - tagline: string; - icon_name?: TWUILucideIconName; - sub_title?: string; - post_component?: ReactNode; -}; - -export default function TaglineSection({ - tagline, - icon_name, - sub_title, - post_component, -}: Props) { - /** - * Render component - */ - return ( -
- - - {icon_name && ( - - )} -

- {tagline} -

- {sub_title ? ( - - {sub_title} - - ) : null} - {post_component} -
-
-
- ); -} +import Section from "@moduletrace/twui/layout/Section"; +import Container from "@moduletrace/twui/layout/Container"; +import H2 from "@moduletrace/twui/layout/H2"; +import Stack from "@moduletrace/twui/layout/Stack"; +import type { LucideIcon } from "lucide-react"; +import Span from "@moduletrace/twui/layout/Span"; +import type { ReactNode } from "react"; + +type Props = { + tagline: string; + icon?: LucideIcon; + sub_title?: string; + post_component?: ReactNode; +}; + +export default function TaglineSection({ + tagline, + icon: Icon, + sub_title, + post_component, +}: Props) { + /** + * Render component + */ + return ( +
+ + + {Icon && } +

+ {tagline} +

+ {sub_title ? ( + + {sub_title} + + ) : null} + {post_component} +
+
+
+ ); +} diff --git a/src/data/app-data.ts b/src/data/app-data.ts index d71f6bd..72047f5 100644 --- a/src/data/app-data.ts +++ b/src/data/app-data.ts @@ -11,6 +11,13 @@ export const AppData = { * bot and workspace. */ ComposerDraftStorageKey: `${SiteData["SiteSlug"]}_composer_draft`, + /** + * Prefix of the chat header's models modal data in `localStorage`, one + * per bot and workspace, and how long it is shown before it is fetched + * again. + */ + HarnessOverviewStorageKey: `${SiteData["SiteSlug"]}_harness_overview`, + HarnessOverviewCacheMs: 10 * 60 * 1000, ServerHealthMessage: "Server Running Well", /** * Where a device downloads the local CA that signed the app's @@ -351,4 +358,20 @@ export const AppData = { * is offline. */ PushTtlSeconds: 60 * 60, + /** + * Responses smaller than this go out uncompressed: the encoding + * costs more than it saves. + */ + CompressMinBytes: 1024, + /** + * Brotli quality for page scripts and styles, compressed once per + * file and kept in memory (`compressResponse`). 11 is about 25 times + * slower for 10% less. + */ + CompressBrotliQuality: 9, + /** + * How many compressed page scripts and styles stay in memory. A + * rebuild names its files anew, so the oldest go first. + */ + CompressCacheMaxFiles: 600, } as const; diff --git a/src/functions/backend/db/bot-sessions/grab-conversation-props.ts b/src/functions/backend/db/bot-sessions/grab-conversation-props.ts index f9d16b1..346d0c2 100644 --- a/src/functions/backend/db/bot-sessions/grab-conversation-props.ts +++ b/src/functions/backend/db/bot-sessions/grab-conversation-props.ts @@ -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/` or * `/admin/workspaces//bots/`): 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 candidates.length > 0) .catch(() => false); } + +/** + * The first text model the bot would try there, if any. + */ +async function grabTextModel( + params: Omit[0], "harness">, +): Promise { + 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, + }; +} diff --git a/src/functions/backend/db/harness/grab-conversation-harness-overview.ts b/src/functions/backend/db/harness/grab-conversation-harness-overview.ts new file mode 100644 index 0000000..808b345 --- /dev/null +++ b/src/functions/backend/db/harness/grab-conversation-harness-overview.ts @@ -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 { + 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, + }); +} diff --git a/src/functions/server/compress-response.ts b/src/functions/server/compress-response.ts new file mode 100644 index 0000000..a07fa04 --- /dev/null +++ b/src/functions/server/compress-response.ts @@ -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>(); + +/** + * 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 { + 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> { + 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; +} diff --git a/src/pages/admin/bots/[bot_id]/(functions)/fetch-harness-overview.ts b/src/pages/admin/bots/[bot_id]/(functions)/fetch-harness-overview.ts new file mode 100644 index 0000000..120ddb5 --- /dev/null +++ b/src/pages/admin/bots/[bot_id]/(functions)/fetch-harness-overview.ts @@ -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 + >(`/api/admin/bots/harness-overview`, { + method: "POST", + body: { bot_id, workspace_id }, + }); +} diff --git a/src/pages/admin/bots/[bot_id]/(hooks)/use-harness-overview.ts b/src/pages/admin/bots/[bot_id]/(hooks)/use-harness-overview.ts new file mode 100644 index 0000000..19045ea --- /dev/null +++ b/src/pages/admin/bots/[bot_id]/(hooks)/use-harness-overview.ts @@ -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(); + const [error, setError] = useState(); + /** + * 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 }; +} diff --git a/src/pages/admin/bots/[bot_id]/(partials)/bot-message-line.tsx b/src/pages/admin/bots/[bot_id]/(partials)/bot-message-line.tsx index 4894427..88099e0 100644 --- a/src/pages/admin/bots/[bot_id]/(partials)/bot-message-line.tsx +++ b/src/pages/admin/bots/[bot_id]/(partials)/bot-message-line.tsx @@ -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) { ); } + +export default memo(BotMessageLine); diff --git a/src/pages/admin/bots/[bot_id]/(partials)/bot-models-modal.tsx b/src/pages/admin/bots/[bot_id]/(partials)/bot-models-modal.tsx index 3c24de6..16d04f9 100644 --- a/src/pages/admin/bots/[bot_id]/(partials)/bot-models-modal.tsx +++ b/src/pages/admin/bots/[bot_id]/(partials)/bot-models-modal.tsx @@ -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 = ( 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.`} - + {harnesses?.length ? ( + { + setHarnesses(fresh); + onSaved(fresh); + }} + /> + ) : error ? ( + {error} + ) : ( + + + + )} ); } diff --git a/src/pages/admin/bots/[bot_id]/(partials)/composer-input-fallback.tsx b/src/pages/admin/bots/[bot_id]/(partials)/composer-input-fallback.tsx new file mode 100644 index 0000000..ca2453f --- /dev/null +++ b/src/pages/admin/bots/[bot_id]/(partials)/composer-input-fallback.tsx @@ -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 ( + + {placeholder} + + ); +} diff --git a/src/pages/admin/bots/[bot_id]/(partials)/composer-input.tsx b/src/pages/admin/bots/[bot_id]/(partials)/composer-input.tsx new file mode 100644 index 0000000..422b53c --- /dev/null +++ b/src/pages/admin/bots/[bot_id]/(partials)/composer-input.tsx @@ -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; + +type Props = Parameters[0] & { + /** + * Gets the editor's commands (`setMarkdown`, `clear`, `insertText`, + * `insertQuote`) for the composer around it. + */ + api_ref: RefObject; + 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 ; +} diff --git a/src/pages/admin/bots/[bot_id]/(partials)/composer.tsx b/src/pages/admin/bots/[bot_id]/(partials)/composer.tsx index ae8ee14..66ff069 100644 --- a/src/pages/admin/bots/[bot_id]/(partials)/composer.tsx +++ b/src/pages/admin/bots/[bot_id]/(partials)/composer.tsx @@ -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(null); + const [editor, setEditor] = useState(null); + const [menu, setMenu] = useState(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 ? : null} + {menu ? : null} { const target = e.target as HTMLElement; @@ -219,7 +226,21 @@ export default function Composer({ disabled={files.full} onFiles={files.add} /> - + }> + setText(markdown)} + onSubmit={() => send_ref.current()} + onFiles={files.add} + invalid={Boolean(error)} + title={error} + /> + {recorder.supported ? ( 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); + }} /> {can_open_settings ? ( diff --git a/src/pages/admin/bots/[bot_id]/(partials)/message-thread.tsx b/src/pages/admin/bots/[bot_id]/(partials)/message-thread.tsx index 830c4e0..70a0636 100644 --- a/src/pages/admin/bots/[bot_id]/(partials)/message-thread.tsx +++ b/src/pages/admin/bots/[bot_id]/(partials)/message-thread.tsx @@ -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(null); const { selection, clear } = useMessageSelection({ ref }); diff --git a/src/pages/admin/bots/[bot_id]/(partials)/user-message.tsx b/src/pages/admin/bots/[bot_id]/(partials)/user-message.tsx index f35ec5c..6235121 100644 --- a/src/pages/admin/bots/[bot_id]/(partials)/user-message.tsx +++ b/src/pages/admin/bots/[bot_id]/(partials)/user-message.tsx @@ -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 ( {message.reply_to ? ( @@ -36,3 +38,5 @@ export default function UserMessage({ message, bot_name }: Props) { ); } + +export default memo(UserMessage); diff --git a/src/pages/admin/bots/[bot_id]/(sections)/conversation.tsx b/src/pages/admin/bots/[bot_id]/(sections)/conversation.tsx index 5bed49a..400eb1a 100644 --- a/src/pages/admin/bots/[bot_id]/(sections)/conversation.tsx +++ b/src/pages/admin/bots/[bot_id]/(sections)/conversation.tsx @@ -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() { - setReplyTo(message), - onReact: reactToMessage, - }} - > + - + {log.plain_text ? ( diff --git a/src/pages/admin/logs/(partials)/log-details-modal.tsx b/src/pages/admin/logs/(partials)/log-details-modal.tsx index ae41557..173a612 100644 --- a/src/pages/admin/logs/(partials)/log-details-modal.tsx +++ b/src/pages/admin/logs/(partials)/log-details-modal.tsx @@ -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" > - + Delete log diff --git a/src/pages/admin/logs/(partials)/log-table-row.tsx b/src/pages/admin/logs/(partials)/log-table-row.tsx index 53316b5..6582f37 100644 --- a/src/pages/admin/logs/(partials)/log-table-row.tsx +++ b/src/pages/admin/logs/(partials)/log-table-row.tsx @@ -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" > - + diff --git a/src/pages/admin/logs/(partials)/logs-toolbar.tsx b/src/pages/admin/logs/(partials)/logs-toolbar.tsx index 15bb90a..b7b47c9 100644 --- a/src/pages/admin/logs/(partials)/logs-toolbar.tsx +++ b/src/pages/admin/logs/(partials)/logs-toolbar.tsx @@ -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" > - + Delete selected ({selected_ids.length}) diff --git a/src/pages/admin/settings/backups/(partials)/create-backup-button.tsx b/src/pages/admin/settings/backups/(partials)/create-backup-button.tsx index c6a4588..9d58c98 100644 --- a/src/pages/admin/settings/backups/(partials)/create-backup-button.tsx +++ b/src/pages/admin/settings/backups/(partials)/create-backup-button.tsx @@ -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 && {status.msg}}