Show sent messages before the server confirms them

The composer now puts the bubble and clears the input as soon as you send, so a slow connection never holds either. The bot's status row reads "<name> is getting your message..." until the saved message arrives (by response or push), then switches to thinking. A failed send removes the bubble and restores the text and files.
This commit is contained in:
2026-10-20 14:50:20 +01:00
parent 4f7e0a6bf0
commit 8425631b3a
9 changed files with 323 additions and 42 deletions
+2 -2
View File
@@ -93,10 +93,10 @@ Inspect live data with `bun llm-code-assistant/query-db.ts "<sql>"` - a read-onl
- **Coordinator:** the install has exactly one `kind: coordinator` bot, the super admin's alone, that coordinates the crew and delegates to every worker. It is created at signup (`bun run seed`) and, as a fallback, by the super admin's `/admin/bots`, all via `ensureCoordinatorBot` (`src/functions/backend/db/bots/ensure-coordinator-bot.ts`, idempotent). Name in `CoordinatorBotName` (`src/dict/bot-dict.ts`). Its default prompt is `CoordinatorSystemPrompt` (`src/data/coordinator-system-prompt.ts`): attached at creation, and backfilled onto a coordinator whose prompt is empty, never over an edited one. - **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. - **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; `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. Selecting text in a message (`data-quotable`, `use-message-selection`) shows a Quote button (`quote-selection-button.tsx`) that inserts it as a blockquote at the cursor (`ComposerHandle.insertQuote`). Before a reply, `grabMessageRefNames` names the refs in the user's messages that the user reaches, and `botMessagesToHarness` adds a `[Mentions: ...]` line after each one (`describeMessageRefs`). `@tiptap/react` needs `use-sync-external-store`, which is CommonJS and calls `require("react")`; bunext keeps React external in page bundles, so the app installs an ESM stand-in from `packages/use-sync-external-store-esm` (a `file:` dependency plus an override; the Dockerfile copies `packages/` before `bun install`). - **Composer:** a Tiptap editor (`(hooks)/use-composer-editor.ts`, `(partials)/composer-editor.tsx`), not a textarea, loaded 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). - **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). - **Replies and reactions:** every bot message has a row under it (`(partials)/message-actions.tsx`, handlers from `BotMessageActionsContext`) to reply or react; the user's own messages only show the bot's reactions, read-only. Replying quotes the message above the composer (`composer-reply-preview.tsx`) and the sent one carries `bot_messages.reply_to_message_id` (not a foreign key: a deleted quote only drops it); `send-message` takes any message of the same conversation (the user's, same bot and workspace, any session, so a rollover or a moved-on thread still sends), and the conversation drops a picked quote when its session changes. Messages carry `reply_to` (`grabMessagesReplies`, also on relayed messages), shown by `message-reply-quote.tsx`, and the bot reads it as `[Replying to ...]` before the user's turn (`botMessagesToHarness`). Reactions are a fixed set (`BotMessageReactions` in `src/dict/bot-dict.ts`) in `bot_message_reactions` (one row per message, emoji and user, or bot); `api/admin/bots/react-message.ts` toggles the user's own on a bot message in their sessions (`grabUserBotMessage`, `toggleMessageReaction`), and every bot gets `react_to_message` (`src/harness/tools/bot/react-to-message-tool.ts`, chat replies and tasks: a message in the session it answers, the user's latest by default). Both push the message's fresh reactions (`grabMessagesReactions`, `mine` for the session user) to the session's user as `server:bot-reaction` (`BotMessageReactionData`), which replaces the message's own; `grabBotMessages` adds them when given `user_id`. Reactions aren't relayed between processes (production runs one).
- **Attachments and thinking:** the composer uploads files as they're picked, pasted or dropped (`use-composer-attachments`), shows them as chips, and sends once none is still uploading; the thread shows images as thumbnails and other files as chips. Replies read the files of the last `BotReplyAttachmentMessages` messages with any (`load-attachment-parts.ts`: images and audio as parts, documents as their text up to `BotAttachmentMaxTextChars`, videos as a note); older ones are only named. Tasks don't get the files yet. While the user has the last word in a session (every user message queues a reply, so this needs no face and shows even while the bot works in another session) or the bot waits for a model (`BotWaitingStatusLine`) or a task for its computer (`BotComputerBusyStatusLine`), `BotThinkingRow` sits under the last message; while a task runs it reads "<name> is working…" with the task's latest step, and a reply working through tools shows its latest step under "is thinking…" (`use-reply-progress`). A coordinator with no workers gets `CoordinatorSoloNote` instead of `CoordinatorReplyNote`, so it never claims to have handed work to a worker. Live notices come from the process that ran the job at once, and from every other process through the live relay. - **Attachments and thinking:** the composer uploads files as they're picked, pasted or dropped (`use-composer-attachments`), shows them as chips, and sends once none is still uploading; the thread shows images as thumbnails and other files as chips. Replies read the files of the last `BotReplyAttachmentMessages` messages with any (`load-attachment-parts.ts`: images and audio as parts, documents as their text up to `BotAttachmentMaxTextChars`, videos as a note); older ones are only named. Tasks don't get the files yet. While the user has the last word in a session (every user message queues a reply, so this needs no face and shows even while the bot works in another session) or the bot waits for a model (`BotWaitingStatusLine`) or a task for its computer (`BotComputerBusyStatusLine`), `BotThinkingRow` sits under the last message; a message still on its way to the server reads "<name> is getting your message…" (`BotGettingMessageStatusLine`), while a task runs it reads "<name> is working…" with the task's latest step, and a reply working through tools shows its latest step under "is thinking…" (`use-reply-progress`). A coordinator with no workers gets `CoordinatorSoloNote` instead of `CoordinatorReplyNote`, so it never claims to have handed work to a worker. Live notices come from the process that ran the job at once, and from every other process through the live relay.
- **Voice:** the composer's mic (`(partials)/composer-voice-button.tsx`, `use-voice-recorder`, shown once the browser can record, disabled with a note while transcription has no model to run on for the bot there, `bot_voice` page prop, from `resolveHarnessCandidates`) dictates: the recording goes to `api/admin/bots/transcribe.ts` (`transcribeAudio` with the bot's models, after `grabUserConversation`) and the text lands in the input at the cursor, to edit and send; Escape cancels, `AppData.ComposerVoiceMaxMs` stops it. Each bot message has a play button (disabled with a note while speech has no model) (`speak-message-button.tsx`, one player per conversation via `SpeakMessageContext` / `use-speak-message`) that plays `GET api/admin/bots/speak-message.ts?message_id=` straight in an `<audio>` (`speakText` streamed through as the provider makes it, so it starts in a couple of seconds; a failure answers in JSON, which the hook fetches again to show; only the user's own sessions, `grabSpeakableMessage`; text from `speechText`, cut at `AppData.SpeechMaxChars`). Under Speech, every model settings form has "Hear a sample" (`settings/models/(partials)/harness-voice-preview.tsx`), which plays `AppData.VoicePreviewText` in the form's model and voice before they're saved (`GET api/admin/harness/preview-voice`, super admins and admins, that model only via `grabModelCandidate`, streamed). Neither recordings nor audio are stored. Harness errors (`HarnessError`, safe to show) come back as the buttons' titles. - **Voice:** the composer's mic (`(partials)/composer-voice-button.tsx`, `use-voice-recorder`, shown once the browser can record, disabled with a note while transcription has no model to run on for the bot there, `bot_voice` page prop, from `resolveHarnessCandidates`) dictates: the recording goes to `api/admin/bots/transcribe.ts` (`transcribeAudio` with the bot's models, after `grabUserConversation`) and the text lands in the input at the cursor, to edit and send; Escape cancels, `AppData.ComposerVoiceMaxMs` stops it. Each bot message has a play button (disabled with a note while speech has no model) (`speak-message-button.tsx`, one player per conversation via `SpeakMessageContext` / `use-speak-message`) that plays `GET api/admin/bots/speak-message.ts?message_id=` straight in an `<audio>` (`speakText` streamed through as the provider makes it, so it starts in a couple of seconds; a failure answers in JSON, which the hook fetches again to show; only the user's own sessions, `grabSpeakableMessage`; text from `speechText`, cut at `AppData.SpeechMaxChars`). Under Speech, every model settings form has "Hear a sample" (`settings/models/(partials)/harness-voice-preview.tsx`), which plays `AppData.VoicePreviewText` in the form's model and voice before they're saved (`GET api/admin/harness/preview-voice`, super admins and admins, that model only via `grabModelCandidate`, streamed). Neither recordings nor audio are stored. Harness errors (`HarnessError`, safe to show) come back as the buttons' titles.
- **Pictures:** a bot or workspace can have a picture (`image_media_id`, a private `media` row, paradigm `bot-image`/`workspace-image`), set or removed through `api/admin/set-profile-image.ts` (`setProfileImage`, the old one deleted; served at `profileImageUrls`). `BotAvatar` shows a bot's picture as a rounded square, else its stone; its hue still applies. `WorkspaceAvatar` shows a workspace's, else the workspace icon. A bot is edited from the header avatar (`(partials)/bot-profile-modal.tsx`, the same `BotForm` as adding one, `api/admin/bots/update-bot.ts`; the coordinator keeps its name and stays hue-less). - **Pictures:** a bot or workspace can have a picture (`image_media_id`, a private `media` row, paradigm `bot-image`/`workspace-image`), set or removed through `api/admin/set-profile-image.ts` (`setProfileImage`, the old one deleted; served at `profileImageUrls`). `BotAvatar` shows a bot's picture as a rounded square, else its stone; its hue still applies. `WorkspaceAvatar` shows a workspace's, else the workspace icon. A bot is edited from the header avatar (`(partials)/bot-profile-modal.tsx`, the same `BotForm` as adding one, `api/admin/bots/update-bot.ts`; the coordinator keeps its name and stays hue-less).
- **Avatars:** `BotStone` (`src/components/general/bot-stone.tsx`) renders the stone inline from `src/data/stone-geometry.ts`, hue as a fill token, face (`idle|working|done|asleep|needs-you`) drawn as eyes. The coordinator is hue-less (ink body, paper eyes; inverts in dark). Never put a stone in a circle/badge or add outline/shadow. Wordmark is vector (`Wordmark`, `src/data/wordmark-paths.ts`), never live text. - **Avatars:** `BotStone` (`src/components/general/bot-stone.tsx`) renders the stone inline from `src/data/stone-geometry.ts`, hue as a fill token, face (`idle|working|done|asleep|needs-you`) drawn as eyes. The coordinator is hue-less (ink body, paper eyes; inverts in dark). Never put a stone in a circle/badge or add outline/shadow. Wordmark is vector (`Wordmark`, `src/data/wordmark-paths.ts`), never live text.
+7
View File
@@ -55,6 +55,13 @@ export const BotNeedsYouStatusLine = "Waiting on you";
*/ */
export const BotWaitingStatusLine = "Waiting for a free model"; export const BotWaitingStatusLine = "Waiting for a free model";
/**
* What the thinking row says while a just-sent message is still on its way
* to the server, before the bot has it and before its reply is queued
* ("<name> is getting your message…").
*/
export const BotGettingMessageStatusLine = "Getting your message";
/** /**
* The bot's status line while its task waits for another job on the same * The bot's status line while its task waits for another job on the same
* computer to finish. * computer to finish.
@@ -104,10 +104,28 @@ export default function useComposerAttachments({ bot_id, workspace_id }: Params)
setPending((prev) => prev.filter((item) => item.key != key)); setPending((prev) => prev.filter((item) => item.key != key));
}; };
/**
* Puts files already uploaded back, after a send that didn't make it.
*/
const restore = ({ attachments }: { attachments: BotMessageAttachment[] }) => {
if (!attachments.length) return;
setPending((prev) => [
...prev,
...attachments.map((attachment) => ({
key: `restored-${attachment.id}-${Date.now()}`,
name: attachment.name,
status: "ready" as const,
attachment,
})),
]);
};
return { return {
pending, pending,
add, add,
remove, remove,
restore,
clear: () => { clear: () => {
revoke({ items: pending }); revoke({ items: pending });
setPending([]); setPending([]);
@@ -5,6 +5,7 @@ import BotAvatar from "@/src/components/general/bot-avatar";
import type { BotHue } from "@/src/dict/bot-dict"; import type { BotHue } from "@/src/dict/bot-dict";
import { import {
BotComputerBusyStatusLine, BotComputerBusyStatusLine,
BotGettingMessageStatusLine,
BotReplyJobTitle, BotReplyJobTitle,
BotWaitingStatusLine, BotWaitingStatusLine,
} from "@/src/dict/bot-queue-dict"; } from "@/src/dict/bot-queue-dict";
@@ -24,6 +25,11 @@ type Props = {
* The thread's last message is the user's, so a reply is due here. * The thread's last message is the user's, so a reply is due here.
*/ */
awaiting_reply?: boolean; awaiting_reply?: boolean;
/**
* The user's last message is still on its way to the server, so the bot
* hasn't got it yet.
*/
receiving?: boolean;
/** /**
* This session has an active task. * This session has an active task.
*/ */
@@ -31,32 +37,43 @@ type Props = {
}; };
/** /**
* Under the last message while the bot is working on a reply or a task * Under the last message while the bot is getting a just-sent message,
* (or waiting for a model or its computer), so slow work doesn't look like * working on a reply or a task (or waiting for a model or its computer), so
* none. A task or a reply shows its latest step. The bot's face is one per user, * slow work doesn't look like none. A task or a reply shows its latest step.
* shared by all their sessions with it, so a reply shows from the thread * The bot's face is one per user, shared by all their sessions with it, so a
* alone: every user message queues one, so while the user has the last * reply shows from the thread alone: every user message queues one, so while
* word a reply is due here, even while the bot works elsewhere. Only the * the user has the last word a reply is due here, even while the bot works
* waits (for a model, for its computer) come from the face. * elsewhere. Only the waits (for a model, for its computer) come from the
* face.
*/ */
export default function BotThinkingRow({ bot, progress, reply_progress, awaiting_reply, has_task }: Props) { export default function BotThinkingRow({
bot,
progress,
reply_progress,
awaiting_reply,
receiving,
has_task,
}: Props) {
const name = bot.name || "The bot"; const name = bot.name || "The bot";
const asleep = bot.face == "asleep"; const asleep = bot.face == "asleep";
const waiting_model = asleep && bot.status_line == BotWaitingStatusLine && Boolean(awaiting_reply || has_task); const waiting_model = asleep && bot.status_line == BotWaitingStatusLine && Boolean(awaiting_reply || has_task);
const waiting_computer = const waiting_computer =
asleep && bot.status_line == BotComputerBusyStatusLine && Boolean(has_task) && !awaiting_reply; asleep && bot.status_line == BotComputerBusyStatusLine && Boolean(has_task) && !awaiting_reply;
const waiting = waiting_model || waiting_computer; const waiting = waiting_model || waiting_computer;
const thinking = Boolean(awaiting_reply) && !waiting; const getting = Boolean(receiving) && Boolean(awaiting_reply);
const thinking = Boolean(awaiting_reply) && !waiting && !getting;
const working = const working =
!awaiting_reply && bot.face == "working" && bot.status_line != BotReplyJobTitle && Boolean(has_task); !awaiting_reply && bot.face == "working" && bot.status_line != BotReplyJobTitle && Boolean(has_task);
if (!thinking && !working && !waiting) return null; if (!getting && !thinking && !working && !waiting) return null;
const label = thinking const label = getting
? `${name} is thinking…` ? `${name} is ${BotGettingMessageStatusLine.toLowerCase()}…`
: working : thinking
? `${name} is working…` ? `${name} is thinking…`
: `${bot.status_line}…`; : working
? `${name} is working…`
: `${bot.status_line}…`;
const detail = working ? progress : thinking ? reply_progress : null; const detail = working ? progress : thinking ? reply_progress : null;
return ( return (
@@ -4,6 +4,7 @@ import Stack from "@moduletrace/twui/layout/Stack";
import P from "@moduletrace/twui/layout/P"; import P from "@moduletrace/twui/layout/P";
import type { import type {
BotExtensionsPayload, BotExtensionsPayload,
BotMessageAttachment,
BotMessageWithAttachments, BotMessageWithAttachments,
BotVoicePayload, BotVoicePayload,
BotWithLastMessage, BotWithLastMessage,
@@ -55,7 +56,18 @@ type Props = {
*/ */
reply_to?: BotMessageWithAttachments | null; reply_to?: BotMessageWithAttachments | null;
onCancelReply?: () => void; onCancelReply?: () => void;
/**
* The message is put in the thread at once, before the server has it.
*/
onSendStart: (params: { message: BotMessageWithAttachments }) => void;
/**
* The server took it, so the pending copy makes way for the saved one.
*/
onSent: (params: { message: BotMessageWithAttachments }) => void; onSent: (params: { message: BotMessageWithAttachments }) => void;
/**
* It didn't make it: the pending copy goes.
*/
onSendFailed: (params: { temp_id: number }) => void;
handle_ref?: Ref<ComposerHandle>; handle_ref?: Ref<ComposerHandle>;
}; };
@@ -68,7 +80,9 @@ type Props = {
* also be pasted or dropped in. Unsent text is kept per conversation * also be pasted or dropped in. Unsent text is kept per conversation
* across reloads, with its uploaded files. The bot's MCP servers and tools * across reloads, with its uploaded files. The bot's MCP servers and tools
* are listed under the input. The mic dictates: what's said is written * are listed under the input. The mic dictates: what's said is written
* into the input at the cursor, to edit before sending. * into the input at the cursor, to edit before sending. Sending shows the
* message in the thread straight away and empties the input, so a slow
* connection never holds either; a failed send puts both back.
*/ */
export default function Composer({ export default function Composer({
bot, bot,
@@ -77,12 +91,13 @@ export default function Composer({
voice, voice,
reply_to, reply_to,
onCancelReply, onCancelReply,
onSendStart,
onSent, onSent,
onSendFailed,
handle_ref, handle_ref,
}: Props) { }: Props) {
const draft = useComposerDraft({ bot_id: bot.id, workspace_id }); const draft = useComposerDraft({ bot_id: bot.id, workspace_id });
const { text, setText } = draft; const { text, setText } = draft;
const [sending, setSending] = useState(false);
const [error, setError] = useState<string>(); const [error, setError] = useState<string>();
const files = useComposerAttachments({ bot_id: bot.id, workspace_id }); const files = useComposerAttachments({ bot_id: bot.id, workspace_id });
const online = useOnlineStatus(); const online = useOnlineStatus();
@@ -90,6 +105,16 @@ export default function Composer({
const workspaces = useContext(CrewWorkspacesContext); const workspaces = useContext(CrewWorkspacesContext);
const send_ref = useRef<() => void>(() => {}); const send_ref = useRef<() => void>(() => {});
/**
* Temporary ids for messages the server doesn't have yet, and the text
* as it stands now, for a failed send that has to put it back.
*/
const temp_id = useRef(0);
const latest_text = useRef(text);
const send_lock = useRef(false);
latest_text.current = text;
const input = useComposerEditor({ const input = useComposerEditor({
placeholder: `Message ${bot.name}`, placeholder: `Message ${bot.name}`,
bots, bots,
@@ -144,35 +169,99 @@ export default function Composer({
const can_send = const can_send =
Boolean(text.trim() || files.media_ids.length) && Boolean(text.trim() || files.media_ids.length) &&
!sending &&
!files.uploading && !files.uploading &&
recorder.state == "idle" && recorder.state == "idle" &&
online; online;
/**
* A send that didn't make it puts its files back, and its text too
* unless something has been typed since.
*/
const restore = ({
lost_text,
attachments,
}: {
lost_text: string;
attachments: BotMessageAttachment[];
}) => {
if (lost_text && !latest_text.current.trim()) {
setText(lost_text);
input.setMarkdown({ markdown: lost_text });
}
files.restore({ attachments });
};
/**
* The bubble and the bot's "getting your message" row show at once, and
* the input empties, so a slow connection never holds any of it. The
* response swaps the bubble for the saved message, or takes it back.
*/
async function send() { async function send() {
if (!can_send || !bot.id) return; if (!can_send || !bot.id || send_lock.current) return;
/** Only for this tick: the cleared editor lands by the next one. */
send_lock.current = true;
setTimeout(() => {
send_lock.current = false;
}, 0);
setSending(true);
setError(undefined); setError(undefined);
const res = await sendBotMessage({ temp_id.current -= 1;
bot_id: bot.id,
workspace_id, const client_id = temp_id.current;
text, const text_sent = text;
media_ids: files.media_ids, const media_ids = files.media_ids;
reply_to_message_id: reply_to?.id || null, const attachments = files.pending.flatMap((item) =>
item.status == "ready" && item.attachment ? [item.attachment] : [],
);
onSendStart({
message: {
id: client_id,
bot_id: bot.id,
workspace_id: workspace_id || undefined,
author: "user",
text: text_sent,
created_at: Date.now(),
updated_at: Date.now(),
attachments,
reply_to: reply_to
? {
id: Number(reply_to.id),
author: reply_to.author == "bot" ? "bot" : "user",
text: reply_to.text || "",
}
: null,
},
}); });
setSending(false); /**
* The message takes the quote with it, so a new one typed while it
if (!res.success || !res.singleRes) { * is on its way doesn't inherit it.
setError(res.msg || "Message not sent"); */
return; onCancelReply?.();
}
setText(""); setText("");
input.clear(); input.clear();
files.clear(); files.clear();
const res = await sendBotMessage({
bot_id: bot.id,
workspace_id,
text: text_sent,
media_ids,
reply_to_message_id: reply_to?.id || null,
});
if (!res.success || !res.singleRes) {
setError(res.msg || "Message not sent");
restore({ lost_text: text_sent, attachments });
onSendFailed({ temp_id: client_id });
return;
}
onSent({ message: res.singleRes }); onSent({ message: res.singleRes });
} }
@@ -233,7 +322,7 @@ export default function Composer({
) : null} ) : null}
<ComposerSendButton <ComposerSendButton
disabled={!can_send} disabled={!can_send}
loading={sending || files.uploading} loading={files.uploading}
onClick={send} onClick={send}
/> />
</Row> </Row>
@@ -21,6 +21,10 @@ type Props = {
* The latest step of the reply running here, shown while it thinks. * The latest step of the reply running here, shown while it thinks.
*/ */
reply_progress?: string | null; reply_progress?: string | null;
/**
* The last message is still on its way to the server.
*/
receiving?: boolean;
/** /**
* This session has an active task. * This session has an active task.
*/ */
@@ -37,7 +41,15 @@ type Props = {
* While the bot works on a reply or a task, a status row sits under the * 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. * last one. Text selected in a message can be quoted in the composer.
*/ */
export default function MessageThread({ bot, messages, progress, reply_progress, has_task, onQuote }: Props) { export default function MessageThread({
bot,
messages,
progress,
reply_progress,
receiving,
has_task,
onQuote,
}: Props) {
const groups = useMemo(() => groupMessages({ messages }), [messages]); const groups = useMemo(() => groupMessages({ messages }), [messages]);
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
const { selection, clear } = useMessageSelection({ ref }); const { selection, clear } = useMessageSelection({ ref });
@@ -64,6 +76,7 @@ export default function MessageThread({ bot, messages, progress, reply_progress,
progress={progress} progress={progress}
reply_progress={reply_progress} reply_progress={reply_progress}
awaiting_reply={messages[messages.length - 1]?.author == "user"} awaiting_reply={messages[messages.length - 1]?.author == "user"}
receiving={receiving}
has_task={has_task} has_task={has_task}
/> />
</Stack> </Stack>
@@ -16,6 +16,7 @@ import { CrewWorkspacesContext } from "../(data)/crew-workspaces-context";
import { BotMessageActionsContext } from "../(data)/message-actions-context"; import { BotMessageActionsContext } from "../(data)/message-actions-context";
import { SpeakMessageContext } from "../(data)/speak-message-context"; import { SpeakMessageContext } from "../(data)/speak-message-context";
import reactToBotMessage from "../(functions)/react-to-bot-message"; import reactToBotMessage from "../(functions)/react-to-bot-message";
import dropSentMessage from "../(utils)/drop-sent-message";
import useBotComputers from "../(hooks)/use-bot-computers"; import useBotComputers from "../(hooks)/use-bot-computers";
import useTaskProgress from "../(hooks)/use-task-progress"; import useTaskProgress from "../(hooks)/use-task-progress";
import useReplyProgress from "../(hooks)/use-reply-progress"; import useReplyProgress from "../(hooks)/use-reply-progress";
@@ -45,6 +46,15 @@ export default function Conversation() {
const [messages, setMessages] = useState<BotMessageWithAttachments[]>( const [messages, setMessages] = useState<BotMessageWithAttachments[]>(
pageProps?.bot_messages || [], pageProps?.bot_messages || [],
); );
/**
* Messages sent here that the server hasn't confirmed yet, shown after
* the saved ones so a slow connection never hides what was typed. They
* give way to the saved message, which arrives by the response or a
* push.
*/
const [pending_messages, setPendingMessages] = useState<
BotMessageWithAttachments[]
>([]);
const [session_id, setSessionId] = useState<number | null>( const [session_id, setSessionId] = useState<number | null>(
pageProps?.bot_session?.id || null, pageProps?.bot_session?.id || null,
); );
@@ -54,10 +64,12 @@ export default function Conversation() {
); );
/** /**
* A quote picked in one session doesn't follow the thread to another. * 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.
*/ */
useEffect(() => { useEffect(() => {
setReplyTo(null); setReplyTo(null);
setPendingMessages([]);
}, [session_id]); }, [session_id]);
const bots = useLiveBots({ const bots = useLiveBots({
@@ -87,18 +99,25 @@ export default function Conversation() {
task: pageProps?.bot_task || null, task: pageProps?.bot_task || null,
}); });
const thread_messages = useMemo(
() => [...messages, ...pending_messages],
[messages, pending_messages],
);
const last_saved_id = messages[messages.length - 1]?.id || null;
const last_thread_message = thread_messages[thread_messages.length - 1];
const awaiting_reply = last_thread_message?.author == "user";
const reply_progress = useReplyProgress({ const reply_progress = useReplyProgress({
bot_id: bot?.id || 0, bot_id: bot?.id || 0,
session_id, session_id,
last_message_id: messages[messages.length - 1]?.id || null, last_message_id: last_saved_id,
}); });
const awaiting_reply = messages[messages.length - 1]?.author == "user";
useReplyCatchUp({ useReplyCatchUp({
session_id, session_id,
awaiting_reply, awaiting_reply,
last_message_id: messages[messages.length - 1]?.id || null, last_message_id: last_saved_id,
}); });
/** /**
@@ -117,6 +136,14 @@ export default function Conversation() {
if (message.bot_id != bot?.id) return; if (message.bot_id != bot?.id) return;
if ((message.workspace_id || null) != workspace_id) return; if ((message.workspace_id || null) != workspace_id) return;
/**
* A pushed copy of a message sent here (its response can come
* after the push) takes the pending bubble's place.
*/
setPendingMessages((prev) =>
dropSentMessage({ pending: prev, message }),
);
if (message.session_id && message.session_id != session_id) { if (message.session_id && message.session_id != session_id) {
if (message.author == "user" && startsNewSession(message)) { if (message.author == "user" && startsNewSession(message)) {
setSessionId(Number(message.session_id)); setSessionId(Number(message.session_id));
@@ -207,9 +234,10 @@ export default function Conversation() {
/> />
<MessageThread <MessageThread
bot={live_bot} bot={live_bot}
messages={messages} messages={thread_messages}
progress={progress.text} progress={progress.text}
reply_progress={reply_progress} reply_progress={reply_progress}
receiving={pending_messages.length > 0}
has_task={Boolean(progress.task_id)} has_task={Boolean(progress.task_id)}
onQuote={({ text }) => onQuote={({ text }) =>
composer.current?.insertQuote({ text }) composer.current?.insertQuote({ text })
@@ -223,8 +251,28 @@ export default function Conversation() {
voice={pageProps?.bot_voice} voice={pageProps?.bot_voice}
reply_to={reply_to} reply_to={reply_to}
onCancelReply={() => setReplyTo(null)} onCancelReply={() => setReplyTo(null)}
onSendStart={({ message }) =>
setPendingMessages((prev) => [
...prev,
message,
])
}
onSendFailed={({ temp_id }) =>
setPendingMessages((prev) =>
prev.filter(
(message) =>
message.id != temp_id,
),
)
}
onSent={({ message }) => { onSent={({ message }) => {
setReplyTo(null); setReplyTo(null);
setPendingMessages((prev) =>
dropSentMessage({
pending: prev,
message,
}),
);
if ( if (
startsNewSession(message) && startsNewSession(message) &&
@@ -0,0 +1,55 @@
import { describe, expect, test } from "bun:test";
import type { BotMessageWithAttachments } from "@/src/types";
import dropSentMessage from "./drop-sent-message";
const pendingMessage = (overrides: Partial<BotMessageWithAttachments> = {}): BotMessageWithAttachments => ({
id: -1,
author: "user",
text: "Hello",
...overrides,
});
const savedMessage = (overrides: Partial<BotMessageWithAttachments> = {}): BotMessageWithAttachments => ({
id: 7,
author: "user",
text: "Hello",
...overrides,
});
describe("dropSentMessage", () => {
test("drops the pending copy the saved message replaces", () => {
const other = pendingMessage({ id: -2, text: "Later" });
expect(
dropSentMessage({ pending: [pendingMessage(), other], message: savedMessage() }),
).toEqual([other]);
});
test("leaves the thread alone when nothing matches", () => {
const pending = [pendingMessage({ text: "Something else" })];
expect(dropSentMessage({ pending, message: savedMessage() })).toBe(pending);
});
test("matches on the quote as well as the text", () => {
const pending = [pendingMessage({ reply_to: { id: 3, author: "bot", text: "Hi" } })];
expect(dropSentMessage({ pending, message: savedMessage({ reply_to_message_id: 3 }) })).toEqual([]);
expect(
dropSentMessage({ pending, message: savedMessage({ reply_to_message_id: 4 }) }),
).toBe(pending);
});
test("only drops one copy of the same words", () => {
const pending = [pendingMessage({ id: -1 }), pendingMessage({ id: -2 })];
expect(dropSentMessage({ pending, message: savedMessage() })).toEqual([pendingMessage({ id: -2 })]);
});
test("never drops a bot message", () => {
const pending = [pendingMessage()];
const message = savedMessage({ author: "bot" });
expect(dropSentMessage({ pending, message })).toBe(pending);
});
});
@@ -0,0 +1,34 @@
import type { BotMessageWithAttachments } from "@/src/types";
type Params = {
/**
* Messages shown before the server has them.
*/
pending: BotMessageWithAttachments[];
/**
* The saved message that has arrived, from the send's response or a
* push.
*/
message: BotMessageWithAttachments;
};
/**
* Takes a pending message off the thread once the saved one arrives, so a
* pushed copy never shows beside the bubble still waiting for its
* response. Only user messages can be pending. The first one that matches
* is enough: same text, same quote, same number of files.
*/
export default function dropSentMessage({ pending, message }: Params): BotMessageWithAttachments[] {
if (!pending.length || message.author != "user") return pending;
const index = pending.findIndex(
(item) =>
item.text == message.text &&
(item.reply_to?.id || null) == (message.reply_to_message_id || null) &&
(item.attachments?.length || 0) == (message.attachments?.length || 0),
);
if (index < 0) return pending;
return [...pending.slice(0, index), ...pending.slice(index + 1)];
}