Trim page props and page the conversation's messages

Bot and workspace lists in page props leave out instructions, memory and
workspace context, and carry the last message as a short preview. The
header avatar's edit form loads the bot's instructions when it opens.
The conversation renders its latest 30 messages and loads older ones as
the thread scrolls to its top.

Also: the composer shows a placeholder until its editor loads, and
`bun start` no longer builds first.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-20 20:09:01 +01:00
co-authored by Claude Opus 5.5
parent b3057119df
commit f13fa863c5
30 changed files with 583 additions and 45 deletions
+2 -2
View File
@@ -28,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` (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) |
| Production | `bun run start` (`MAIN_SERVER=1 NODE_ENV=production bun src/server.ts`, no build: it serves whatever `.bunext/` holds, a dev build included, and builds only when there is none; build for production first with `NODE_ENV=production bunx bunext build`) |
| Type-check only | `bun run typecheck` |
| Bundle budget (after a build) | `bun run check:bundle` |
| Run tests | `bun run test` |
@@ -92,7 +92,7 @@ Inspect live data with `bun llm-code-assistant/query-db.ts "<sql>"` - a read-onl
- **Threads and sessions:** each user has one conversation with a bot in its own chat and one per workspace it's in (the coordinator in every workspace). Each is split into `bot_sessions` (`user_id`, `bot_id`, `workspace_id` null for its own chat, `status` active|closed, `title` and `summary` written by the bot from the first exchange, `summary_message_count` (how many messages the summary covers), `previous_session_id`, `unread`, `last_activity_at`); `bot_messages`, `bot_tasks` and `bots_queue` carry `session_id` and `workspace_id`. Helpers in `src/functions/backend/db/bot-sessions/`: `ensureBotSession` (the active one, else a new one; `rollover` starts the next once it has `AppData.BotSessionMaxMessages`), `startBotSession`, `reopenBotSession`, `grabBotSessions` (search over title, summary and message text), `grabUserConversation` (access and membership), `grabConversationProps` (the conversation pages' props). Once a session's first exchange is in, `name-bot-session.ts` has the bot name it (the title the rail and list show) and write its summary, after the reply is pushed; a new session's first reply gets the previous one's summary (`grab-session-carry-over.ts`), rewritten first when it covers fewer messages than that session ended with. The header's sessions icon (`src/components/general/bot-sessions-modal.tsx`) lists, searches, starts and reopens sessions, and the new-session icon beside it (`(partials)/new-session-button.tsx`) starts one (`api/admin/bots/list-sessions`, `new-session`, `reopen-session`). The rail lists the open bot's latest `AppData.RailRecentSessions` sessions under its row (`rail-bot-sessions.tsx`), and once there are more, "All sessions" opens the same modal. Unread, the rail's last message and activity are per thread, from its sessions. Job results go into the job's own session, even after it closes.
- **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.
- **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; The page renders a session's latest `AppData.ConversationFirstMessages` messages (`bot_messages_more` says there are older), and the thread loads older ones `AppData.ConversationOlderMessages` at a time as it scrolls to its top (`older-messages.ts`, `use-older-messages`, `older-messages-loader.tsx`). Bot and workspace lists in page props go through `railBots` / `railWorkspaces` (`src/utils/`): no core instructions, memory or workspace context, and the last message cut to a preview (`AppData.RailPreviewMaxChars`); the header avatar's edit form loads the instructions when it opens (`bot-instructions.ts`, `use-bot-instructions`). `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, loaded with the page and focused automatically, that reads and writes markdown (`@tiptap/markdown`), so drafts and sent messages are markdown. Drafts are written to `localStorage` on a 250ms debounce (`useComposerDraft`), flushed on unmount. 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. Sending shows the bubble at once and empties the input (with its quote and files), so a slow connection never waits on the request: the optimistic message rides in `pending_messages` in `conversation.tsx` until the response or a push replaces it with the saved one (`(utils)/drop-sent-message.ts`), and a failed send takes it back and restores the text and files. 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).
+1 -1
View File
@@ -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": "NODE_ENV=production bunx bunext build && MAIN_SERVER=1 NODE_ENV=production bun src/server.ts",
"start": "MAIN_SERVER=1 NODE_ENV=production bun src/server.ts",
"typecheck": "tsc --noEmit",
"test": "bun test",
"check:bundle": "bun src/scripts/check-bundle-size.ts"
+14
View File
@@ -297,6 +297,20 @@ export const AppData = {
* How much of the conversation a bot sees when it replies.
*/
BotReplyHistoryMessages: 40,
/**
* How many of a session's latest messages the conversation page
* renders with; older ones load as the thread is scrolled up.
*/
ConversationFirstMessages: 30,
/**
* How many older messages each scroll to the top of the thread loads.
*/
ConversationOlderMessages: 30,
/**
* How much of a bot's last message the rail's lists carry, as plain
* text (`messagePreview`): a row shows one line of it.
*/
RailPreviewMaxChars: 160,
/**
* A session with this many messages is closed when the user writes
* again, and the message starts the next one (carrying a summary).
@@ -1,4 +1,8 @@
import type { BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
import { AppData } from "@/src/data/app-data";
import type { BotTextModel, PagePropsType } from "@/src/types";
import railBots from "@/src/utils/rail-bots";
import railWorkspaces from "@/src/utils/rail-workspaces";
import grabBots from "../bots/grab-bots";
import grabActiveBotTask from "../bots/grab-active-bot-task";
import grabBotMessages from "../bots/grab-bot-messages";
@@ -23,7 +27,8 @@ type Params = {
* The page props of a conversation (`/admin/bots/<id>` or
* `/admin/workspaces/<id>/bots/<id>`): the rail's crew (the workspace's
* in a workspace) and workspaces, the bot, its active session (marked
* read) and its messages, the text model it runs on, its computers,
* read) and its latest `AppData.ConversationFirstMessages` messages (the
* thread loads older ones as it scrolls), 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: the install-wide catalog (`api/admin/harness/catalog`)
@@ -63,7 +68,11 @@ export default async function grabConversationProps({
bot.unread = 0;
const [bot_messages, bot_text_model, bot_computers, bot_task, bot_extensions, transcription, speech] = await Promise.all([
grabBotMessages({ session_id: session.id!, user_id }),
grabBotMessages({
session_id: session.id!,
user_id,
limit: AppData.ConversationFirstMessages + 1,
}),
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 }),
@@ -72,13 +81,16 @@ export default async function grabConversationProps({
hasHarnessModel({ harness: "speech", bot_id: bot.id, workspace_id: scope }),
]);
const bot_messages_more = bot_messages.length > AppData.ConversationFirstMessages;
return {
bots,
bot,
workspaces,
workspace: conversation.workspace,
bots: railBots({ bots }),
bot: railBots({ bots: [bot] })[0],
workspaces: railWorkspaces({ workspaces }),
workspace: conversation.workspace ? withoutContext(conversation.workspace) : null,
bot_session: session,
bot_messages,
bot_messages: bot_messages_more ? bot_messages.slice(1) : bot_messages,
bot_messages_more,
bot_text_model,
bot_computers,
bot_task,
@@ -87,6 +99,14 @@ export default async function grabConversationProps({
};
}
/**
* The open workspace without its shared context, which the conversation
* never shows (the bot reads it from the database).
*/
function withoutContext({ system_prompt, ...workspace }: BUN_SQLITE_COBBLE_WORKSPACES) {
return workspace;
}
/**
* Whether the harness is on and has a model to run on for the bot there.
*/
@@ -9,6 +9,10 @@ import mediaToAttachment from "./media-to-attachment";
type Params = {
session_id: number | string;
limit?: number;
/**
* Only messages older than this one, for loading further back.
*/
before_id?: number | null;
/**
* Whose conversation it is, so a reaction can show as the user's own.
* Left out for the bot's own history reads, which don't need it.
@@ -17,12 +21,14 @@ type Params = {
};
/**
* The latest messages of a session, oldest first, with their attachments,
* The latest messages of a session (or the latest before `before_id`),
* oldest first, with their attachments,
* what each replies to, and (when `user_id` is given) its reactions.
*/
export default async function grabBotMessages({
session_id,
limit = 100,
before_id,
user_id,
}: Params): Promise<BotMessageWithAttachments[]> {
let sql = ``;
@@ -30,13 +36,18 @@ export default async function grabBotMessages({
sql += `SELECT * FROM (\n`;
sql += ` SELECT * FROM bot_messages\n`;
sql += ` WHERE session_id = ?\n`;
if (before_id) {
sql += ` AND (created_at, id) < (SELECT created_at, id FROM bot_messages WHERE id = ?)\n`;
}
sql += ` ORDER BY created_at DESC, id DESC\n`;
sql += ` LIMIT ?\n`;
sql += `) ORDER BY created_at ASC, id ASC\n`;
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_BOT_MESSAGES>({
sql,
values: [session_id, limit],
values: before_id ? [session_id, before_id, limit] : [session_id, limit],
});
const messages = res.payload || [];
+5 -1
View File
@@ -5,7 +5,11 @@ type Params = {
/**
* Under `/api/admin/bots/`.
*/
path: "list-sessions" | "new-session" | "reopen-session" | "session-messages";
path: | "list-sessions"
| "new-session"
| "reopen-session"
| "session-messages"
| "older-messages";
body: Record<string, unknown>;
};
@@ -0,0 +1,13 @@
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
import type { APIResponseObject } from "@moduletrace/bunext/types";
import type { BotIdRequest } from "@/src/schemas/bot-schema";
/**
* A bot's core instructions, for its edit form.
*/
export default async function fetchBotInstructions({ bot_id }: BotIdRequest) {
return await fetchApi<BotIdRequest, APIResponseObject<{ system_prompt: string }>>(
`/api/admin/bots/bot-instructions`,
{ method: "POST", body: { bot_id } },
);
}
@@ -0,0 +1,50 @@
import { useEffect, useState } from "react";
import fetchBotInstructions from "../(functions)/fetch-bot-instructions";
type Params = {
bot_id: number;
/**
* Loads each time this turns true (the edit form opens), so the form
* starts from what was last saved.
*/
enabled: boolean;
};
/**
* The bot's core instructions for its edit form, loaded when it opens:
* page props leave them out of every bot row.
*/
export default function useBotInstructions({ bot_id, enabled }: Params) {
const [system_prompt, setSystemPrompt] = useState<string>();
const [error, setError] = useState<string>();
useEffect(() => {
/** Cleared on close, so the next open never shows an old copy. */
setSystemPrompt(undefined);
setError(undefined);
if (!enabled || !bot_id) return;
let cancelled = false;
fetchBotInstructions({ bot_id })
.then((res) => {
if (cancelled) return;
if (res.success && res.singleRes) {
setSystemPrompt(res.singleRes.system_prompt);
} else {
setError(res.msg || "Couldn't load the instructions.");
}
})
.catch(() => {
if (!cancelled) setError("Couldn't load the instructions.");
});
return () => {
cancelled = true;
};
}, [enabled, bot_id]);
return { system_prompt, error };
}
@@ -0,0 +1,67 @@
import { useEffect, useRef, useState } from "react";
import postSessionApi from "@/src/functions/frontend/post-session-api";
import type { BotMessageWithAttachments, OlderBotMessagesData } from "@/src/types";
type Params = {
session_id?: number | null;
/**
* The page rendered with only the session's latest messages.
*/
has_more: boolean;
/**
* The thread's oldest message: the next page ends before it.
*/
first_message_id?: number | null;
/**
* Takes a loaded page, oldest first, to put above the thread.
*/
onLoaded: (params: { messages: BotMessageWithAttachments[] }) => void;
};
/**
* Loads the session's older messages a page at a time, when the thread
* is scrolled to its top. A session the thread moves onto starts with
* its first message, so it has none older.
*/
export default function useOlderMessages({
session_id,
has_more: initial_has_more,
first_message_id,
onLoaded,
}: Params) {
const [has_more, setHasMore] = useState(initial_has_more);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string>();
const first_session = useRef(session_id);
useEffect(() => {
if (session_id == first_session.current) return;
setHasMore(false);
setError(undefined);
}, [session_id]);
const loadOlder = async () => {
if (loading || !has_more || !session_id || !first_message_id) return;
setLoading(true);
setError(undefined);
const res = await postSessionApi<OlderBotMessagesData>({
path: "older-messages",
body: { session_id, before_id: first_message_id },
}).catch(() => null);
setLoading(false);
if (!res?.success || !res.singleRes) {
setError(res?.msg || "Couldn't load earlier messages.");
return;
}
onLoaded({ messages: res.singleRes.messages });
setHasMore(res.singleRes.has_more);
};
return { has_more, loading, error, loadOlder };
}
@@ -1,5 +1,6 @@
import { useContext, useState } from "react";
import Modal from "@moduletrace/twui/elements/Modal";
import Loading from "@moduletrace/twui/elements/Loading";
import Button from "@moduletrace/twui/layout/Button";
import H2 from "@moduletrace/twui/layout/H2";
import Span from "@moduletrace/twui/layout/Span";
@@ -10,6 +11,7 @@ import type { BotWithLastMessage } from "@/src/types";
import BotForm from "@/src/layouts/bots/(partials)/bot-form";
import { AppContext } from "@/src/pages/__root";
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
import useBotInstructions from "../(hooks)/use-bot-instructions";
type Props = {
bot: BotWithLastMessage;
@@ -18,12 +20,17 @@ type Props = {
/**
* The header's avatar: opens the bot's profile (picture, name, job,
* colour, instructions) to edit, for super admins and admins. Everyone
* else sees the avatar alone.
* else sees the avatar alone. The instructions load when it opens.
*/
export default function BotProfileModal({ bot }: Props) {
const { pageProps } = useContext(AppContext);
const [open, setOpen] = useState(false);
const name = bot.name || "This bot";
const can_edit = canManageCrew({ user_types: pageProps?.user_types });
const { system_prompt, error } = useBotInstructions({
bot_id: Number(bot.id) || 0,
enabled: open && can_edit,
});
const avatar = (
<BotAvatar
@@ -34,7 +41,7 @@ export default function BotProfileModal({ bot }: Props) {
/>
);
if (!canManageCrew({ user_types: pageProps?.user_types })) {
if (!can_edit) {
return <Stack className="shrink-0">{avatar}</Stack>;
}
@@ -65,9 +72,15 @@ export default function BotProfileModal({ bot }: Props) {
: `${name}'s core instructions apply everywhere. What it does in one workspace is set on that workspace's page.`}
</Span>
</Stack>
{open ? (
<BotForm bot={bot} onCancel={() => setOpen(false)} />
) : null}
{!open ? null : system_prompt != undefined ? (
<BotForm bot={{ ...bot, system_prompt }} onCancel={() => setOpen(false)} />
) : 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>
);
}
@@ -1,5 +1,6 @@
import { EditorContent, type Editor } from "@tiptap/react";
import ComposerFormatBubble from "./composer-format-bubble";
import LoadingBlock from "@moduletrace/twui/elements/LoadingBlock";
type Props = {
editor: Editor | null;
@@ -21,7 +22,11 @@ export default function ComposerEditor({ editor, invalid, title }: Props) {
title={title}
className="grow min-w-0 self-center"
/>
{editor ? <ComposerFormatBubble editor={editor} /> : null}
{editor ? (
<ComposerFormatBubble editor={editor} />
) : (
<LoadingBlock className="w-full h-[30px] -ml-6" />
)}
</>
);
}
@@ -1,4 +1,11 @@
import { useContext, useEffect, useImperativeHandle, useRef, useState, type Ref } from "react";
import {
useContext,
useEffect,
useImperativeHandle,
useRef,
useState,
type Ref,
} from "react";
import Row from "@moduletrace/twui/layout/Row";
import Stack from "@moduletrace/twui/layout/Stack";
import P from "@moduletrace/twui/layout/P";
@@ -125,7 +132,11 @@ export default function Composer({
});
const editor = input.editor;
useImperativeHandle(handle_ref, () => ({ insertQuote: input.insertQuote }), [input.insertQuote]);
useImperativeHandle(
handle_ref,
() => ({ insertQuote: input.insertQuote }),
[input.insertQuote],
);
/**
* The saved draft goes into the editor once per conversation, after
@@ -134,7 +145,8 @@ export default function Composer({
const applied_key = useRef<string | null>(null);
useEffect(() => {
if (!editor || !draft.loaded || applied_key.current == draft.key) return;
if (!editor || !draft.loaded || applied_key.current == draft.key)
return;
applied_key.current = draft.key;
@@ -156,7 +168,11 @@ export default function Composer({
onRecorded: async ({ audio }) => {
if (!bot.id) return;
const res = await transcribeVoice({ bot_id: bot.id, workspace_id, audio });
const res = await transcribeVoice({
bot_id: bot.id,
workspace_id,
audio,
});
if (!res.success || !res.singleRes?.text) {
recorder.setError(res.msg || "Couldn't write that out");
@@ -294,7 +310,8 @@ export default function Composer({
/>
) : null}
{input.menu ? <ComposerMentionList menu={input.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`}
<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;
@@ -308,7 +325,13 @@ export default function Composer({
disabled={files.full}
onFiles={files.add}
/>
<ComposerEditor editor={editor} invalid={Boolean(error)} title={error} />
<ComposerEditor
editor={editor}
invalid={Boolean(error)}
title={error}
/>
{recorder.supported ? (
<ComposerVoiceButton
state={recorder.state}
@@ -327,11 +350,18 @@ export default function Composer({
/>
</Row>
{recorder.error ? (
<P role="alert" className="w-full max-w-container mx-auto px-3 md:px-6 -mt-5 pb-3 text-xs text-ink-secondary">
<P
role="alert"
className="w-full max-w-container mx-auto px-3 md:px-6 -mt-5 pb-3 text-xs text-ink-secondary"
>
{recorder.error}
</P>
) : null}
<ComposerTools bot={bot} workspace_id={workspace_id} initial={extensions} />
<ComposerTools
bot={bot}
workspace_id={workspace_id}
initial={extensions}
/>
</Stack>
</Stack>
);
@@ -8,6 +8,7 @@ import useMessageSelection from "../(hooks)/use-message-selection";
import groupMessages from "../(utils)/group-messages";
import BotThinkingRow from "./bot-thinking-row";
import MessageGroupBlock from "./message-group-block";
import OlderMessagesLoader from "./older-messages-loader";
import QuoteSelectionButton from "./quote-selection-button";
type Props = {
@@ -33,6 +34,15 @@ type Props = {
* Quotes text selected in a message in the composer.
*/
onQuote?: (params: { text: string }) => void;
/**
* The session's older messages, loaded at the top of the thread.
*/
older?: {
has_more: boolean;
loading: boolean;
error?: string;
loadOlder: () => void;
};
};
/**
@@ -40,6 +50,8 @@ type Props = {
* upward. Empty space belongs at the top. New messages announce politely.
* While the bot works on a reply or a task, a status row sits under the
* last one. Text selected in a message can be quoted in the composer.
* Older messages load as it scrolls to the top; the reversed column keeps
* the view where it was as they land above it.
*/
export default function MessageThread({
bot,
@@ -49,6 +61,7 @@ export default function MessageThread({
receiving,
has_task,
onQuote,
older,
}: Props) {
const groups = useMemo(() => groupMessages({ messages }), [messages]);
const ref = useRef<HTMLDivElement>(null);
@@ -64,6 +77,13 @@ export default function MessageThread({
aria-label={`Conversation with ${bot.name}`}
className="w-full max-w-container mx-auto gap-4! items-stretch px-3 md:px-6 pt-6 pb-6"
>
{older?.has_more ? (
<OlderMessagesLoader
loading={older.loading}
error={older.error}
onLoad={older.loadOlder}
/>
) : null}
{groups.map((group) => (
<MessageGroupBlock
key={group.key}
@@ -0,0 +1,60 @@
import { useEffect, useRef, type RefObject } from "react";
import Loading from "@moduletrace/twui/elements/Loading";
import Button from "@moduletrace/twui/layout/Button";
import Stack from "@moduletrace/twui/layout/Stack";
type Props = {
loading: boolean;
error?: string;
onLoad: () => void;
};
/**
* The top of a thread with older messages: loads them as it scrolls into
* view, with a button for when it can't (or a load failed).
*/
export default function OlderMessagesLoader({ loading, error, onLoad }: Props) {
const ref = useRef<HTMLDivElement>(null);
const load = useRef(onLoad);
load.current = onLoad;
useEffect(() => {
const el = ref.current;
if (!el || error || typeof IntersectionObserver == "undefined") return;
const observer = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting)) load.current();
},
{ rootMargin: "400px 0px 0px 0px" },
);
observer.observe(el);
return () => observer.disconnect();
}, [error, loading]);
return (
<Stack
componentRef={ref as RefObject<HTMLDivElement>}
className="w-full items-center justify-center min-h-8"
>
{loading ? (
<Loading size="smaller" />
) : (
<Button
title="Show earlier messages"
type="button"
variant="ghost"
color="text"
onClick={onLoad}
className="text-[13px]! leading-[18px]! text-ink-secondary"
>
{error ? `${error} Try again` : "Show earlier messages"}
</Button>
)}
</Stack>
);
}
@@ -22,6 +22,7 @@ import useTaskProgress from "../(hooks)/use-task-progress";
import useReplyProgress from "../(hooks)/use-reply-progress";
import useReplyCatchUp from "../(hooks)/use-reply-catch-up";
import useSpeakMessage from "../(hooks)/use-speak-message";
import useOlderMessages from "../(hooks)/use-older-messages";
import ConversationHeader from "../(partials)/conversation-header";
import MessageThread from "../(partials)/message-thread";
import Composer, { type ComposerHandle } from "../(partials)/composer";
@@ -35,7 +36,8 @@ import ComputerViewerModal from "../(partials)/computer-viewer-modal";
* buttons in the thread, so a switch from either shows in both; either
* can open one in the viewer. Bot messages can be read aloud, one at a
* time. Text selected in the thread can be quoted in the composer, which
* mentions the crew and workspaces the user sees.
* mentions the crew and workspaces the user sees. The page brings the
* session's latest messages; older ones load as the thread scrolls up.
*/
export default function Conversation() {
const { pageProps } = useContext(AppContext);
@@ -99,6 +101,19 @@ export default function Conversation() {
task: pageProps?.bot_task || null,
});
const older = useOlderMessages({
session_id,
has_more: Boolean(pageProps?.bot_messages_more),
first_message_id: Number(messages[0]?.id) || null,
onLoaded: ({ messages: older_messages }) =>
setMessages((prev) => [
...older_messages.filter(
(message) => !prev.some((m) => m.id == message.id),
),
...prev,
]),
});
const thread_messages = useMemo(
() => [...messages, ...pending_messages],
[messages, pending_messages],
@@ -239,6 +254,7 @@ export default function Conversation() {
reply_progress={reply_progress}
receiving={pending_messages.length > 0}
has_task={Boolean(progress.task_id)}
older={older}
onQuote={({ text }) =>
composer.current?.insertQuote({ text })
}
+3 -1
View File
@@ -6,6 +6,8 @@ import grabUserWorkspaces from "@/src/functions/backend/db/workspaces/grab-user-
import type { PagePropsType } from "@/src/types";
import crewRole from "@/src/utils/rbac/crew-role";
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
import railBots from "@/src/utils/rail-bots";
import railWorkspaces from "@/src/utils/rail-workspaces";
/**
* Opens a conversation. The super admin lands on the coordinator, which
@@ -44,5 +46,5 @@ export const server: BunextPageServerFn<PagePropsType> = async ({ req }) => {
return { redirect: { destination: `/admin/workspaces/${workspaces[0].id}` } };
}
return { props: { bots, workspaces } };
return { props: { bots: railBots({ bots }), workspaces: railWorkspaces({ workspaces }) } };
};
@@ -10,6 +10,8 @@ import grabHarnessOverview from "@/src/functions/backend/db/harness/grab-harness
import grabBotWorkspaces from "@/src/functions/backend/db/workspaces/grab-bot-workspaces";
import grabUserWorkspaces from "@/src/functions/backend/db/workspaces/grab-user-workspaces";
import grabCrewSettingsServerProps from "../../(functions)/grab-crew-settings-server-props";
import railBots from "@/src/utils/rail-bots";
import railWorkspaces from "@/src/utils/rail-workspaces";
/**
* One bot's settings: its details, models, workspaces, computers, tools
@@ -63,8 +65,8 @@ export const server: BunextPageServerFn<PagePropsType> = async (params) => {
props: {
...settings.props,
bot,
bots,
workspaces,
bots: railBots({ bots }),
workspaces: railWorkspaces({ workspaces }),
bot_workspaces,
bot_computers,
computers: all_computers,
@@ -2,6 +2,7 @@ import type { PagePropsType } from "@/src/types";
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
import grabBots from "@/src/functions/backend/db/bots/grab-bots";
import grabCrewSettingsServerProps from "../(functions)/grab-crew-settings-server-props";
import railBots from "@/src/utils/rail-bots";
export const server: BunextPageServerFn<PagePropsType> = async (params) => {
const settings = await grabCrewSettingsServerProps(params);
@@ -11,7 +12,7 @@ export const server: BunextPageServerFn<PagePropsType> = async (params) => {
return {
props: {
...settings.props,
bots: await grabBots({ user_id: settings.props.person.id }),
bots: railBots({ bots: await grabBots({ user_id: settings.props.person.id }) }),
},
};
};
@@ -6,6 +6,8 @@ import grabUserComputer from "@/src/functions/backend/db/computers/grab-user-com
import grabUserComputers from "@/src/functions/backend/db/computers/grab-user-computers";
import grabUserWorkspaces from "@/src/functions/backend/db/workspaces/grab-user-workspaces";
import grabCrewSettingsServerProps from "../../(functions)/grab-crew-settings-server-props";
import railBots from "@/src/utils/rail-bots";
import railWorkspaces from "@/src/utils/rail-workspaces";
/**
* One computer's settings: its state and resources, direct bot access,
@@ -52,8 +54,8 @@ export const server: BunextPageServerFn<PagePropsType> = async (params) => {
props: {
...settings.props,
computer,
bots,
workspaces,
bots: railBots({ bots }),
workspaces: railWorkspaces({ workspaces }),
computer_bots: access.bots,
computer_workspaces: access.workspaces,
},
@@ -8,6 +8,8 @@ import grabUserWorkspaces from "@/src/functions/backend/db/workspaces/grab-user-
import grabWorkspaceBots from "@/src/functions/backend/db/workspaces/grab-workspace-bots";
import grabWorkspaceComputers from "@/src/functions/backend/db/workspaces/grab-workspace-computers";
import grabCrewSettingsServerProps from "../../(functions)/grab-crew-settings-server-props";
import railBots from "@/src/utils/rail-bots";
import railWorkspaces from "@/src/utils/rail-workspaces";
/**
* One workspace's settings: its picture, details, bots, computers, tools
@@ -52,14 +54,16 @@ export const server: BunextPageServerFn<PagePropsType> = async (params) => {
grabWorkspaceExtensionsOverview({ user_id, workspace_id }),
]);
const crew = railBots({ bots: workspace_bots });
return {
props: {
...settings.props,
workspaces,
workspaces: railWorkspaces({ workspaces }),
workspace,
bots: workspace_bots,
workspace_bots,
all_bots,
bots: crew,
workspace_bots: crew,
all_bots: railBots({ bots: all_bots }),
workspace_computers,
computers,
workspace_harness_overview,
@@ -2,6 +2,7 @@ import type { PagePropsType } from "@/src/types";
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
import grabUserWorkspaces from "@/src/functions/backend/db/workspaces/grab-user-workspaces";
import grabCrewSettingsServerProps from "../(functions)/grab-crew-settings-server-props";
import railWorkspaces from "@/src/utils/rail-workspaces";
export const server: BunextPageServerFn<PagePropsType> = async (params) => {
const settings = await grabCrewSettingsServerProps(params);
@@ -11,7 +12,9 @@ export const server: BunextPageServerFn<PagePropsType> = async (params) => {
return {
props: {
...settings.props,
workspaces: railWorkspaces({
workspaces: await grabUserWorkspaces({ user_id: settings.props.person.id }),
}),
},
};
};
@@ -6,6 +6,8 @@ import grabWorkspaceComputers from "@/src/functions/backend/db/workspaces/grab-w
import { AdminHomePath } from "@/src/dict/admin-sections-dict";
import type { PagePropsType, PageQueryObject } from "@/src/types";
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
import railBots from "@/src/utils/rail-bots";
import railWorkspaces from "@/src/utils/rail-workspaces";
/**
* A workspace's dashboard: its picture, context, latest activity, bots and
@@ -38,12 +40,14 @@ export const server: BunextPageServerFn<PagePropsType> = async (params) => {
grabWorkspaceActivity({ user_id: user.id, workspace_id }),
]);
const crew = railBots({ bots: workspace_bots });
return {
props: {
workspaces,
workspaces: railWorkspaces({ workspaces }),
workspace,
bots: workspace_bots,
workspace_bots,
bots: crew,
workspace_bots: crew,
workspace_computers,
workspace_activity,
},
@@ -0,0 +1,47 @@
import userAuth from "@/src/functions/backend/auth/user-auth";
import grabUserBot from "@/src/functions/backend/db/bots/grab-user-bot";
import { BotIdRequestSchema } from "@/src/schemas/bot-schema";
import AppError from "@/src/utils/app-error";
import parseWithSchema from "@/src/utils/parse-with-schema";
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
import safeErrorMessage from "@/src/utils/safe-error-message";
import type {
APIResponseObject,
BunextAPIRouteHandler,
} from "@moduletrace/bunext/types";
/**
* A bot's core instructions, for its edit form in the conversation, which
* loads them when it opens (page props leave them out). For super admins
* and admins; only the super admin reaches the coordinator.
*/
export const handler: BunextAPIRouteHandler<
APIResponseObject<{ system_prompt: string }>
> = async ({ req, body }) => {
try {
if (req.method != "POST") {
throw new AppError(`Method not allowed`);
}
const { user, user_types } = await userAuth({ req });
if (!user?.logged_in_status || !user.id) {
return { success: false, msg: `Unauthorized`, logoutUser: true };
}
if (!canManageCrew({ user_types })) {
throw new AppError(`Only an admin can edit a bot.`);
}
const { bot_id } = parseWithSchema({ schema: BotIdRequestSchema, data: body });
const bot = await grabUserBot({ bot_id, user_id: user.id });
if (!bot?.id) {
throw new AppError(`Bot not found.`);
}
return { success: true, singleRes: { system_prompt: bot.system_prompt || "" } };
} catch (error: unknown) {
return { success: false, msg: safeErrorMessage({ error }) };
}
};
@@ -0,0 +1,60 @@
import userAuth from "@/src/functions/backend/auth/user-auth";
import grabUserBotSession from "@/src/functions/backend/db/bot-sessions/grab-user-bot-session";
import grabBotMessages from "@/src/functions/backend/db/bots/grab-bot-messages";
import { AppData } from "@/src/data/app-data";
import { OlderBotMessagesRequestSchema } from "@/src/schemas/bot-schema";
import type { OlderBotMessagesData } from "@/src/types";
import AppError from "@/src/utils/app-error";
import parseWithSchema from "@/src/utils/parse-with-schema";
import safeErrorMessage from "@/src/utils/safe-error-message";
import type {
APIResponseObject,
BunextAPIRouteHandler,
} from "@moduletrace/bunext/types";
/**
* The messages before `before_id` in one of the session user's own
* sessions, `AppData.ConversationOlderMessages` at a time, for the thread
* scrolled to its top.
*/
export const handler: BunextAPIRouteHandler<
APIResponseObject<OlderBotMessagesData>
> = async ({ req, body }) => {
try {
if (req.method != "POST") {
throw new AppError(`Method not allowed`);
}
const { user } = await userAuth({ req });
if (!user?.logged_in_status || !user.id) {
return { success: false, msg: `Unauthorized`, logoutUser: true };
}
const { session_id, before_id } = parseWithSchema({
schema: OlderBotMessagesRequestSchema,
data: body,
});
const session = await grabUserBotSession({ session_id, user_id: user.id });
if (!session?.id) {
throw new AppError(`Session not found.`);
}
const messages = await grabBotMessages({
session_id: session.id,
before_id,
limit: AppData.ConversationOlderMessages + 1,
user_id: user.id,
});
const has_more = messages.length > AppData.ConversationOlderMessages;
return {
success: true,
singleRes: { messages: has_more ? messages.slice(1) : messages, has_more },
};
} catch (error: unknown) {
return { success: false, msg: safeErrorMessage({ error }) };
}
};
@@ -12,6 +12,7 @@ import type {
APIResponseObject,
BunextAPIRouteHandler,
} from "@moduletrace/bunext/types";
import railBots from "@/src/utils/rail-bots";
/**
* The crew's workers that aren't in a workspace yet, for the workspace
@@ -48,9 +49,11 @@ export const handler: BunextAPIRouteHandler<APIResponseObject<BotWithLastMessage
return {
success: true,
payload: bots.filter(
payload: railBots({
bots: bots.filter(
(bot) => bot.kind != "coordinator" && !members.some((member) => member.id == bot.id),
),
}),
};
} catch (error: unknown) {
return { success: false, msg: safeErrorMessage({ error }) };
+6
View File
@@ -77,6 +77,12 @@ export const BotSessionIdRequestSchema = z.object({
export type BotSessionIdRequest = z.infer<typeof BotSessionIdRequestSchema>;
export const OlderBotMessagesRequestSchema = BotSessionIdRequestSchema.extend({
before_id: z.coerce.number().int().positive("Pick a message"),
});
export type OlderBotMessagesRequest = z.infer<typeof OlderBotMessagesRequestSchema>;
export const BotConversationRequestSchema = z.object({
bot_id: z.coerce.number().int().positive("Pick a bot"),
workspace_id: WorkspaceIdField,
+13
View File
@@ -666,6 +666,15 @@ export type BotMessageWithAttachments = BUN_SQLITE_COBBLE_BOT_MESSAGES & {
/**
* `server:bot-reaction` data: a message's reactions changed.
*/
/**
* A page of a session's older messages, oldest first, and whether there
* are older still.
*/
export type OlderBotMessagesData = {
messages: BotMessageWithAttachments[];
has_more: boolean;
};
export type BotMessageReactionData = {
message_id: number;
reactions: BotMessageReaction[];
@@ -725,6 +734,10 @@ export type PagePropsType = {
bots?: BotWithLastMessage[] | null;
bot?: BotWithLastMessage | null;
bot_messages?: BotMessageWithAttachments[] | null;
/**
* The session has messages older than `bot_messages`.
*/
bot_messages_more?: boolean | null;
/**
* The open conversation's active session.
*/
+36
View File
@@ -0,0 +1,36 @@
import { describe, expect, test } from "bun:test";
import { AppData } from "@/src/data/app-data";
import type { BotWithLastMessage } from "@/src/types";
import railBots from "./rail-bots";
import railWorkspaces from "./rail-workspaces";
describe("railBots", () => {
test("drops the core instructions and memory", () => {
const bots: BotWithLastMessage[] = [
{ id: 1, name: "Ada", system_prompt: "Long rules", memory: "Notes" },
];
const [bot] = railBots({ bots });
expect(bot).toEqual({ id: 1, name: "Ada", last_message_text: undefined });
});
test("cuts the last message to a plain one-line preview", () => {
const bots: BotWithLastMessage[] = [
{ id: 1, last_message_text: `**Done.**\n\n${"word ".repeat(100)}` },
];
const [bot] = railBots({ bots });
expect(bot?.last_message_text?.startsWith("Done. word")).toBe(true);
expect(bot?.last_message_text?.length).toBe(AppData.RailPreviewMaxChars);
});
});
describe("railWorkspaces", () => {
test("drops the shared context", () => {
expect(
railWorkspaces({
workspaces: [{ id: 2, name: "Site", system_prompt: "Context", unread: 0 }],
}),
).toEqual([{ id: 2, name: "Site", unread: 0 }]);
});
});
+18
View File
@@ -0,0 +1,18 @@
import { AppData } from "@/src/data/app-data";
import type { BotWithLastMessage } from "@/src/types";
import messagePreview from "./message-preview";
/**
* Bots as page props carry them for the rail and the crew lists: without
* their core instructions and memory (the largest part of a row, and only
* the bot's own edit form reads them, loading them when it opens), and
* with the last message cut to a one-line preview.
*/
export default function railBots<T extends BotWithLastMessage>({ bots }: { bots: T[] }): T[] {
return bots.map(({ system_prompt, memory, ...bot }) => ({
...bot,
last_message_text: bot.last_message_text
? messagePreview({ text: bot.last_message_text }).slice(0, AppData.RailPreviewMaxChars)
: bot.last_message_text,
})) as T[];
}
+14
View File
@@ -0,0 +1,14 @@
import type { WorkspaceWithActivity } from "@/src/types";
/**
* Workspaces as page props carry them for the rail: without their shared
* context (`system_prompt`), which only the open workspace's own pages
* read, from their `workspace` prop.
*/
export default function railWorkspaces({
workspaces,
}: {
workspaces: WorkspaceWithActivity[];
}): WorkspaceWithActivity[] {
return workspaces.map(({ system_prompt, ...workspace }) => workspace);
}