Load a conversation's history on the client
The conversation pages rendered their messages on the server, so a page the back button restored from the back-forward cache showed an old thread. The server now renders everything else (rail, header, composer) and the history (active session, latest messages, active task) loads from api/admin/bots/conversation-history once the page is on screen, with a loading line under the header, and again on a pageshow restore. Messages sent or pushed while it loads are merged with it. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -101,7 +101,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` opens the bot this browser was last in (`readLastVisitedBot` from the single `cobble_last_visited_bot_id` cookie, which the conversation pages set through `withLastVisitedBotCookies` in their `res_transform`, and which `/auth/logout` clears; a conversation inside a workspace is never remembered, so the landing page always reopens the bot's own chat), but only while `grabReachableConversation` says the user still reaches it. Otherwise it 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`, only a file whose extension the extractor unpacks gets `text_content` - `canExtractText`, cut to `AppData.BotAttachmentTextStoreMaxChars` - 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.
|
||||
- **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`, only a file whose extension the extractor unpacks gets `text_content` - `canExtractText`, cut to `AppData.BotAttachmentTextStoreMaxChars` - 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 conversation pages render everything on the server (`grabConversationProps`: rail, bot, header's model and computers, composer's tools and voice, files) except the history, so the header and composer show at once. The history loads on the client: `Conversation` posts to `api/admin/bots/conversation-history` (`use-conversation-history`, `grabConversationHistory`: the active session, marked read, its latest messages and the bot's active task there; or a `redirect` when the user can't chat there any more) with a `LoadingLine` (`src/components/general/loading-line.tsx`, the `animate-loading-line` theme animation in `src/styles/main.css`) under the header meanwhile, and again on a back-forward cache restore (`pageshow` with `persisted`), so the back button never shows an old thread. A load is merged with what the thread already has (`merge-loaded-history`: messages sent or pushed while it was on its way stay) and resets the older-messages loader and the task progress. The history holds 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).
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
type Props = {
|
||||
label?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* A thin line sweeping across, set just under a pane header while its
|
||||
* page loads. Takes no height (it overlays the top of what follows), so
|
||||
* nothing moves when it goes. Under reduced motion it holds still.
|
||||
*/
|
||||
export default function LoadingLine({ label = "Loading", className }: Props) {
|
||||
return (
|
||||
<Stack
|
||||
role="progressbar"
|
||||
aria-label={label}
|
||||
className={twMerge("relative h-0 w-full shrink-0 gap-0! z-10", className)}
|
||||
>
|
||||
<Stack className="absolute inset-x-0 top-0 h-0.5 gap-0! overflow-hidden bg-ink/5">
|
||||
<Span
|
||||
className={twMerge(
|
||||
"block h-full w-1/3 bg-linear-to-r from-transparent via-ink to-transparent",
|
||||
"animate-loading-line",
|
||||
"motion-reduce:animate-none motion-reduce:w-full motion-reduce:opacity-30",
|
||||
)}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import type { ConversationHistory } from "@/src/types";
|
||||
import grabActiveBotTask from "../bots/grab-active-bot-task";
|
||||
import grabBotMessages from "../bots/grab-bot-messages";
|
||||
import ensureBotSession from "./ensure-bot-session";
|
||||
import grabUserConversation from "./grab-user-conversation";
|
||||
import markBotSessionRead from "./mark-bot-session-read";
|
||||
|
||||
type Params = {
|
||||
user_id: number;
|
||||
bot_id: number | string;
|
||||
/**
|
||||
* The workspace the conversation is in; none for the bot's own chat.
|
||||
*/
|
||||
workspace_id?: number | string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* A conversation's history, which the conversation pages load on the
|
||||
* client once they render (`api/admin/bots/conversation-history`): the
|
||||
* bot's active session there (marked read), its latest
|
||||
* `AppData.ConversationFirstMessages` messages (the thread loads older
|
||||
* ones as it scrolls) and the bot's active task in it. Everything else on
|
||||
* the page comes with it from the server (`grabConversationProps`).
|
||||
* Null when the user can't chat with the bot there.
|
||||
*/
|
||||
export default async function grabConversationHistory({
|
||||
user_id,
|
||||
bot_id,
|
||||
workspace_id,
|
||||
}: Params): Promise<ConversationHistory | null> {
|
||||
const conversation = await grabUserConversation({ user_id, bot_id, workspace_id }).catch(
|
||||
() => null,
|
||||
);
|
||||
|
||||
if (!conversation?.bot?.id) return null;
|
||||
|
||||
const open_bot_id = conversation.bot.id;
|
||||
const session = await ensureBotSession({
|
||||
user_id,
|
||||
bot_id: open_bot_id,
|
||||
workspace_id: conversation.workspace_id,
|
||||
});
|
||||
|
||||
if (session.unread) {
|
||||
await markBotSessionRead({ session_id: session.id!, user_id });
|
||||
session.unread = 0;
|
||||
}
|
||||
|
||||
const [messages, task] = await Promise.all([
|
||||
grabBotMessages({
|
||||
session_id: session.id!,
|
||||
user_id,
|
||||
limit: AppData.ConversationFirstMessages + 1,
|
||||
}),
|
||||
grabActiveBotTask({ bot_id: open_bot_id, session_id: session.id }),
|
||||
]);
|
||||
|
||||
const has_more = messages.length > AppData.ConversationFirstMessages;
|
||||
|
||||
return {
|
||||
session,
|
||||
messages: has_more ? messages.slice(1) : messages,
|
||||
has_more,
|
||||
task,
|
||||
};
|
||||
}
|
||||
@@ -1,19 +1,14 @@
|
||||
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";
|
||||
import grabBotExtensionsOverview from "../extensions/grab-bot-extensions-overview";
|
||||
import grabConversationFiles from "../bot-files/grab-conversation-files";
|
||||
import grabBotComputers from "../computers/grab-bot-computers";
|
||||
import resolveHarnessCandidates from "@/src/harness/resolve/resolve-harness-candidates";
|
||||
import grabUserWorkspaces from "../workspaces/grab-user-workspaces";
|
||||
import ensureBotSession from "./ensure-bot-session";
|
||||
import grabUserConversation from "./grab-user-conversation";
|
||||
import markBotSessionRead from "./mark-bot-session-read";
|
||||
|
||||
type Params = {
|
||||
user_id: number;
|
||||
@@ -27,11 +22,12 @@ 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 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
|
||||
* in a workspace) and workspaces, the bot, the text model it runs on, its
|
||||
* computers, tools, MCP servers and files there, and whether its mic and
|
||||
* read-aloud have a model to run on. The history (its session, messages
|
||||
* and active task) is left to the client (`grabConversationHistory`,
|
||||
* `api/admin/bots/conversation-history`), so a page the back button
|
||||
* restores never shows an old thread. The full model settings are left to
|
||||
* the models modal: the install-wide catalog (`api/admin/harness/catalog`)
|
||||
* and the conversation's small setting
|
||||
* (`api/admin/bots/harness-overview`). They are most of a page's weight
|
||||
@@ -50,12 +46,6 @@ export default async function grabConversationProps({
|
||||
if (!conversation) return null;
|
||||
|
||||
const scope = conversation.workspace_id;
|
||||
const session = await ensureBotSession({ user_id, bot_id: conversation.bot.id, workspace_id: scope });
|
||||
|
||||
if (session.unread) {
|
||||
await markBotSessionRead({ session_id: session.id!, user_id });
|
||||
session.unread = 0;
|
||||
}
|
||||
|
||||
const [bots, workspaces] = await Promise.all([
|
||||
grabBots({ user_id, workspace_id: scope }),
|
||||
@@ -66,36 +56,25 @@ export default async function grabConversationProps({
|
||||
|
||||
if (!bot?.id) return null;
|
||||
|
||||
/** Loading its history marks the open session read. */
|
||||
bot.unread = 0;
|
||||
|
||||
const [bot_messages, bot_text_model, bot_computers, bot_task, bot_extensions, bot_files, transcription, speech] = await Promise.all([
|
||||
grabBotMessages({
|
||||
session_id: session.id!,
|
||||
user_id,
|
||||
limit: AppData.ConversationFirstMessages + 1,
|
||||
}),
|
||||
const [bot_text_model, bot_computers, bot_extensions, bot_files, transcription, speech] = await Promise.all([
|
||||
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 }),
|
||||
grabConversationFiles({ bot_id: bot.id, workspace_id: scope }),
|
||||
hasHarnessModel({ harness: "transcription", bot_id: bot.id, workspace_id: scope }),
|
||||
hasHarnessModel({ harness: "speech", bot_id: bot.id, workspace_id: scope }),
|
||||
]);
|
||||
|
||||
const bot_messages_more = bot_messages.length > AppData.ConversationFirstMessages;
|
||||
|
||||
return {
|
||||
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_more ? bot_messages.slice(1) : bot_messages,
|
||||
bot_messages_more,
|
||||
bot_text_model,
|
||||
bot_computers,
|
||||
bot_task,
|
||||
bot_extensions,
|
||||
bot_files,
|
||||
bot_voice: { transcription, speech },
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
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 { ConversationHistoryPayload } from "@/src/types";
|
||||
|
||||
/**
|
||||
* A conversation's history, loaded once the page is on screen.
|
||||
*/
|
||||
export default async function fetchConversationHistory({
|
||||
bot_id,
|
||||
workspace_id,
|
||||
}: BotConversationRequest) {
|
||||
return await fetchApi<
|
||||
BotConversationRequest,
|
||||
APIResponseObject<ConversationHistoryPayload>
|
||||
>(`/api/admin/bots/conversation-history`, {
|
||||
method: "POST",
|
||||
body: { bot_id, workspace_id },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { ConversationHistory } from "@/src/types";
|
||||
import fetchConversationHistory from "../(functions)/fetch-conversation-history";
|
||||
|
||||
type Params = {
|
||||
bot_id: number;
|
||||
workspace_id: number | null;
|
||||
/**
|
||||
* Takes each load: the thread starts over from it.
|
||||
*/
|
||||
onLoaded: (params: { history: ConversationHistory }) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Loads the open conversation's history once the page is on screen, and
|
||||
* again whenever the browser restores the page from its back-forward
|
||||
* cache (`pageshow` with `persisted`), so going back never shows an old
|
||||
* thread. Everything else on the page (the header, the composer) comes
|
||||
* from the server and shows at once. `loaded` turns true with the first
|
||||
* load; a conversation the user can no longer open sends them where the
|
||||
* server says.
|
||||
*/
|
||||
export default function useConversationHistory({ bot_id, workspace_id, onLoaded }: Params) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [error, setError] = useState<string>();
|
||||
const request = useRef(0);
|
||||
const on_loaded = useRef(onLoaded);
|
||||
|
||||
on_loaded.current = onLoaded;
|
||||
|
||||
const load = async () => {
|
||||
if (!bot_id) return;
|
||||
|
||||
const id = ++request.current;
|
||||
|
||||
setLoading(true);
|
||||
setError(undefined);
|
||||
|
||||
const res = await fetchConversationHistory({ bot_id, workspace_id }).catch(() => null);
|
||||
|
||||
/** A newer load started meanwhile: it has the last word. */
|
||||
if (id != request.current) return;
|
||||
|
||||
if (res?.success && res.singleRes?.redirect) {
|
||||
window.location.replace(res.singleRes.redirect);
|
||||
return;
|
||||
}
|
||||
|
||||
const history = res?.success ? res.singleRes?.history : null;
|
||||
|
||||
setLoading(false);
|
||||
|
||||
if (!history?.session?.id) {
|
||||
setError(res?.msg || "Couldn't load the conversation.");
|
||||
return;
|
||||
}
|
||||
|
||||
on_loaded.current({ history });
|
||||
setLoaded(true);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
|
||||
const onPageShow = (event: PageTransitionEvent) => {
|
||||
if (event.persisted) load();
|
||||
};
|
||||
|
||||
window.addEventListener("pageshow", onPageShow);
|
||||
return () => window.removeEventListener("pageshow", onPageShow);
|
||||
}, [bot_id, workspace_id]);
|
||||
|
||||
return { loading, loaded, error, retry: load };
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useCallback, 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.
|
||||
* The thread starts with only the session's latest messages.
|
||||
*/
|
||||
has_more: boolean;
|
||||
/**
|
||||
@@ -21,7 +21,8 @@ type Params = {
|
||||
/**
|
||||
* 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.
|
||||
* its first message, so it has none older; `reset` starts over from a
|
||||
* loaded history instead.
|
||||
*/
|
||||
export default function useOlderMessages({
|
||||
session_id,
|
||||
@@ -32,15 +33,28 @@ export default function useOlderMessages({
|
||||
const [has_more, setHasMore] = useState(initial_has_more);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string>();
|
||||
const first_session = useRef(session_id);
|
||||
const base_session = useRef(session_id);
|
||||
|
||||
useEffect(() => {
|
||||
if (session_id == first_session.current) return;
|
||||
if (session_id == base_session.current) return;
|
||||
|
||||
base_session.current = session_id;
|
||||
setHasMore(false);
|
||||
setError(undefined);
|
||||
}, [session_id]);
|
||||
|
||||
/**
|
||||
* A history loaded for the session: whether it has older messages.
|
||||
*/
|
||||
const reset = useCallback(
|
||||
({ session_id, has_more }: { session_id: number; has_more: boolean }) => {
|
||||
base_session.current = session_id;
|
||||
setHasMore(has_more);
|
||||
setError(undefined);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const loadOlder = async () => {
|
||||
if (loading || !has_more || !session_id || !first_message_id) return;
|
||||
|
||||
@@ -63,5 +77,5 @@ export default function useOlderMessages({
|
||||
setHasMore(res.singleRes.has_more);
|
||||
};
|
||||
|
||||
return { has_more, loading, error, loadOlder };
|
||||
return { has_more, loading, error, loadOlder, reset };
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import useServerEvent from "@/src/hooks/use-server-event";
|
||||
import type { BotJobUpdateData, BotTaskWithSteps, BotUpdatedData } from "@/src/types";
|
||||
|
||||
@@ -9,7 +9,7 @@ type Params = {
|
||||
*/
|
||||
session_id?: number | null;
|
||||
/**
|
||||
* The bot's active task when the page loaded.
|
||||
* The bot's active task when the thread started.
|
||||
*/
|
||||
task: BotTaskWithSteps | null;
|
||||
};
|
||||
@@ -30,7 +30,7 @@ function fromTask({ task }: { task: BotTaskWithSteps | null }): Progress {
|
||||
* The open session's active task and its latest step (a
|
||||
* `bots_queue_updates` row's text), kept current from
|
||||
* `server:bot-job-update`. Starts over when `server:bot-updated` brings a
|
||||
* different task, or none.
|
||||
* different task, or none, or `reset` brings a loaded history's.
|
||||
*/
|
||||
export default function useTaskProgress({ bot_id, session_id, task }: Params) {
|
||||
const [progress, setProgress] = useState<Progress>(() => fromTask({ task }));
|
||||
@@ -60,5 +60,9 @@ export default function useTaskProgress({ bot_id, session_id, task }: Params) {
|
||||
},
|
||||
});
|
||||
|
||||
return progress;
|
||||
const reset = useCallback(({ task }: { task: BotTaskWithSteps | null }) => {
|
||||
setProgress(fromTask({ task }));
|
||||
}, []);
|
||||
|
||||
return { ...progress, reset };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
|
||||
type Props = {
|
||||
error: string;
|
||||
onRetry: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Under the header when the conversation's history couldn't load: why,
|
||||
* and a way to try again.
|
||||
*/
|
||||
export default function HistoryErrorRow({ error, onRetry }: Props) {
|
||||
return (
|
||||
<Row className="shrink-0 gap-3! flex-nowrap justify-center px-6 py-3 border-0 border-b border-solid border-hairline">
|
||||
<Span className="text-sm text-ink-secondary">{error}</Span>
|
||||
<Button title="Try again" type="button" variant="ghost" color="text" onClick={onRetry}>
|
||||
Try again
|
||||
</Button>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
@@ -33,6 +33,10 @@ import ConversationHeader from "../(partials)/conversation-header";
|
||||
import MessageThread from "../(partials)/message-thread";
|
||||
import Composer, { type ComposerHandle } from "../(partials)/composer";
|
||||
import ComputerViewerModal from "../(partials)/computer-viewer-modal";
|
||||
import HistoryErrorRow from "../(partials)/history-error-row";
|
||||
import useConversationHistory from "../(hooks)/use-conversation-history";
|
||||
import mergeLoadedHistory from "../(utils)/merge-loaded-history";
|
||||
import LoadingLine from "@/src/components/general/loading-line";
|
||||
|
||||
/**
|
||||
* The conversation column: header, bottom-anchored thread, composer, the
|
||||
@@ -42,8 +46,12 @@ 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. The page brings the
|
||||
* session's latest messages; older ones load as the thread scrolls up.
|
||||
* mentions the crew and workspaces the user sees. The page brings
|
||||
* everything but the history, so the header and composer show at once;
|
||||
* the history (the session's latest messages and the bot's task there)
|
||||
* loads on the client with a loading line under the header, and again
|
||||
* when the back button restores the page. Older messages load as the
|
||||
* thread scrolls up.
|
||||
*/
|
||||
export default function Conversation() {
|
||||
const { pageProps } = useContext(AppContext);
|
||||
@@ -52,7 +60,7 @@ export default function Conversation() {
|
||||
const workspace_id = workspace?.id || null;
|
||||
|
||||
const [messages, setMessages] = useState<BotMessageWithAttachments[]>(
|
||||
pageProps?.bot_messages || [],
|
||||
[],
|
||||
);
|
||||
/**
|
||||
* Messages sent here that the server hasn't confirmed yet, shown after
|
||||
@@ -63,9 +71,7 @@ export default function Conversation() {
|
||||
const [pending_messages, setPendingMessages] = useState<
|
||||
BotMessageWithAttachments[]
|
||||
>([]);
|
||||
const [session_id, setSessionId] = useState<number | null>(
|
||||
pageProps?.bot_session?.id || null,
|
||||
);
|
||||
const [session_id, setSessionId] = useState<number | null>(null);
|
||||
const [has_other_unread, setHasOtherUnread] = useState(false);
|
||||
const [reply_to, setReplyTo] = useState<BotMessageWithAttachments | null>(
|
||||
null,
|
||||
@@ -73,9 +79,19 @@ export default function Conversation() {
|
||||
|
||||
/**
|
||||
* A quote picked in one session doesn't follow the thread to another,
|
||||
* and neither does a message still on its way to the old one.
|
||||
* and neither does a message still on its way to the old one. The
|
||||
* first session the thread learns of isn't a move: what was sent
|
||||
* before the history landed stays.
|
||||
*/
|
||||
const known_session = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const moved = Boolean(known_session.current);
|
||||
|
||||
known_session.current = session_id;
|
||||
|
||||
if (!moved) return;
|
||||
|
||||
setReplyTo(null);
|
||||
setPendingMessages([]);
|
||||
}, [session_id]);
|
||||
@@ -108,12 +124,12 @@ export default function Conversation() {
|
||||
const progress = useTaskProgress({
|
||||
bot_id: bot?.id || 0,
|
||||
session_id,
|
||||
task: pageProps?.bot_task || null,
|
||||
task: null,
|
||||
});
|
||||
|
||||
const older = useOlderMessages({
|
||||
session_id,
|
||||
has_more: Boolean(pageProps?.bot_messages_more),
|
||||
has_more: false,
|
||||
first_message_id: Number(messages[0]?.id) || null,
|
||||
onLoaded: ({ messages: older_messages }) =>
|
||||
setMessages((prev) => [
|
||||
@@ -124,6 +140,19 @@ export default function Conversation() {
|
||||
]),
|
||||
});
|
||||
|
||||
const history = useConversationHistory({
|
||||
bot_id: Number(bot?.id) || 0,
|
||||
workspace_id,
|
||||
onLoaded: ({ history }) => {
|
||||
const loaded_session_id = Number(history.session.id);
|
||||
|
||||
setSessionId(loaded_session_id);
|
||||
setMessages((prev) => mergeLoadedHistory({ shown: prev, history }));
|
||||
older.reset({ session_id: loaded_session_id, has_more: history.has_more });
|
||||
progress.reset({ task: history.task });
|
||||
},
|
||||
});
|
||||
|
||||
const thread_messages = useMemo(
|
||||
() => [...messages, ...pending_messages],
|
||||
[messages, pending_messages],
|
||||
@@ -169,7 +198,15 @@ export default function Conversation() {
|
||||
dropSentMessage({ pending: prev, message }),
|
||||
);
|
||||
|
||||
if (message.session_id && message.session_id != session_id) {
|
||||
/**
|
||||
* Before the history lands the thread has no session yet: the
|
||||
* message waits in the thread, and the history merges with it.
|
||||
*/
|
||||
if (
|
||||
session_id &&
|
||||
message.session_id &&
|
||||
message.session_id != session_id
|
||||
) {
|
||||
if (message.author == "user" && startsNewSession(message)) {
|
||||
setSessionId(Number(message.session_id));
|
||||
setMessages([message]);
|
||||
@@ -276,6 +313,15 @@ export default function Conversation() {
|
||||
workspace={workspace}
|
||||
has_other_unread={has_other_unread}
|
||||
/>
|
||||
{history.loading ? (
|
||||
<LoadingLine label="Loading the conversation" />
|
||||
) : null}
|
||||
{history.error ? (
|
||||
<HistoryErrorRow
|
||||
error={history.error}
|
||||
onRetry={history.retry}
|
||||
/>
|
||||
) : null}
|
||||
<MessageThread
|
||||
bot={live_bot}
|
||||
messages={thread_messages}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type { BotMessageWithAttachments, ConversationHistory } from "@/src/types";
|
||||
import mergeLoadedHistory from "./merge-loaded-history";
|
||||
|
||||
function message({ id, session_id = 1 }: { id: number; session_id?: number }) {
|
||||
return { id, session_id, author: "bot", text: `#${id}` } as BotMessageWithAttachments;
|
||||
}
|
||||
|
||||
function history({ ids, session_id = 1 }: { ids: number[]; session_id?: number }) {
|
||||
return {
|
||||
session: { id: session_id },
|
||||
messages: ids.map((id) => message({ id, session_id })),
|
||||
has_more: false,
|
||||
task: null,
|
||||
} as ConversationHistory;
|
||||
}
|
||||
|
||||
const ids = (messages: BotMessageWithAttachments[]) => messages.map((m) => m.id);
|
||||
|
||||
describe("mergeLoadedHistory", () => {
|
||||
test("an empty thread takes the history", () => {
|
||||
expect(ids(mergeLoadedHistory({ shown: [], history: history({ ids: [1, 2] }) }))).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
test("keeps what arrived after the history, once", () => {
|
||||
const shown = [message({ id: 2 }), message({ id: 3 })];
|
||||
|
||||
expect(ids(mergeLoadedHistory({ shown, history: history({ ids: [1, 2] }) }))).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
test("an older copy of the thread gives way", () => {
|
||||
const shown = [message({ id: 1 }), message({ id: 2 })];
|
||||
|
||||
expect(ids(mergeLoadedHistory({ shown, history: history({ ids: [2, 4, 5] }) }))).toEqual([2, 4, 5]);
|
||||
});
|
||||
|
||||
test("another session's messages give way", () => {
|
||||
const shown = [message({ id: 9, session_id: 1 })];
|
||||
|
||||
expect(
|
||||
ids(mergeLoadedHistory({ shown, history: history({ ids: [10], session_id: 2 }) })),
|
||||
).toEqual([10]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { BotMessageWithAttachments, ConversationHistory } from "@/src/types";
|
||||
|
||||
type Params = {
|
||||
/**
|
||||
* What the thread shows now.
|
||||
*/
|
||||
shown: BotMessageWithAttachments[];
|
||||
history: ConversationHistory;
|
||||
};
|
||||
|
||||
/**
|
||||
* The thread once a history lands: its messages, then any the thread
|
||||
* already has from the same session that came after them (sent or pushed
|
||||
* while the history was on its way). Everything else the thread showed
|
||||
* (an older copy of the thread, another session's) gives way to it.
|
||||
*/
|
||||
export default function mergeLoadedHistory({ shown, history }: Params) {
|
||||
const loaded = history.messages;
|
||||
const last_id = Number(loaded[loaded.length - 1]?.id) || 0;
|
||||
|
||||
const later = shown.filter(
|
||||
(message) =>
|
||||
message.session_id == history.session.id &&
|
||||
Number(message.id) > last_id &&
|
||||
!loaded.some((m) => m.id == message.id),
|
||||
);
|
||||
|
||||
return [...loaded, ...later];
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||
import grabConversationHistory from "@/src/functions/backend/db/bot-sessions/grab-conversation-history";
|
||||
import { BotConversationRequestSchema } from "@/src/schemas/bot-schema";
|
||||
import type { ConversationHistoryPayload } from "@/src/types";
|
||||
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 conversation's history (`grabConversationHistory`: its active session,
|
||||
* latest messages and the bot's active task there), which the
|
||||
* conversation pages load once they are on screen instead of rendering it
|
||||
* on the server, so a page restored by the back button never shows an
|
||||
* old thread. When the user can't chat with the bot there any more,
|
||||
* `redirect` says where the page goes instead: their bots, or the
|
||||
* workspace (its settings for managers).
|
||||
*/
|
||||
export const handler: BunextAPIRouteHandler<
|
||||
APIResponseObject<ConversationHistoryPayload>
|
||||
> = 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 };
|
||||
}
|
||||
|
||||
const { bot_id, workspace_id } = parseWithSchema({
|
||||
schema: BotConversationRequestSchema,
|
||||
data: body,
|
||||
});
|
||||
|
||||
const history = await grabConversationHistory({
|
||||
user_id: Number(user.id),
|
||||
bot_id,
|
||||
workspace_id,
|
||||
});
|
||||
|
||||
if (!history) {
|
||||
const redirect = !workspace_id
|
||||
? "/admin/bots"
|
||||
: canManageCrew({ user_types })
|
||||
? `/admin/settings/workspaces/${workspace_id}`
|
||||
: `/admin/workspaces/${workspace_id}`;
|
||||
|
||||
return { success: true, singleRes: { redirect } };
|
||||
}
|
||||
|
||||
return { success: true, singleRes: { history } };
|
||||
} catch (error: unknown) {
|
||||
return { success: false, msg: safeErrorMessage({ error }) };
|
||||
}
|
||||
};
|
||||
@@ -184,6 +184,19 @@
|
||||
/* The rail's full (named) width: below it the rail shows icons only.
|
||||
A container query, so the mobile drawer opens full too. */
|
||||
--container-rail: 220px;
|
||||
|
||||
/* The thin line under a pane header while its page loads
|
||||
(`LoadingLine`): a short bar sweeping across, again and again. */
|
||||
--animate-loading-line: loading-line 1.2s ease-in-out infinite;
|
||||
|
||||
@keyframes loading-line {
|
||||
0% {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(300%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
|
||||
+26
-13
@@ -1080,6 +1080,32 @@ export type BotTextModel = {
|
||||
provider_name: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* A conversation's history, which its page loads on the client once it
|
||||
* renders (`grabConversationHistory`): the active session, its latest
|
||||
* messages, whether it has older ones, and the bot's active task in it.
|
||||
*/
|
||||
export type ConversationHistory = {
|
||||
session: BUN_SQLITE_COBBLE_BOT_SESSIONS;
|
||||
messages: BotMessageWithAttachments[];
|
||||
has_more: boolean;
|
||||
/**
|
||||
* The bot's active task in the session and what its job has done so
|
||||
* far.
|
||||
*/
|
||||
task: BotTaskWithSteps | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* What `api/admin/bots/conversation-history` answers: the history, or
|
||||
* where to go instead when the user can't chat with the bot there any
|
||||
* more.
|
||||
*/
|
||||
export type ConversationHistoryPayload = {
|
||||
history?: ConversationHistory;
|
||||
redirect?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* One build's `.bunext/public` files, downloaded by the service worker
|
||||
* before the app runs (`grabPrecacheManifest`).
|
||||
@@ -1107,15 +1133,6 @@ export type PagePropsType = {
|
||||
crew_workspaces?: BUN_SQLITE_COBBLE_WORKSPACES[] | null;
|
||||
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.
|
||||
*/
|
||||
bot_session?: BUN_SQLITE_COBBLE_BOT_SESSIONS | null;
|
||||
/**
|
||||
* The user's workspaces, for the rail.
|
||||
*/
|
||||
@@ -1158,10 +1175,6 @@ export type PagePropsType = {
|
||||
* The open bot's computers, the one it's on first.
|
||||
*/
|
||||
bot_computers?: ComputerWithStatus[] | null;
|
||||
/**
|
||||
* The open bot's active task and what its job has done so far.
|
||||
*/
|
||||
bot_task?: BotTaskWithSteps | null;
|
||||
/**
|
||||
* The routines page: every schedule the user's bots have.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user