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:
@@ -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.
|
||||
- **Routes:** `/admin/bots` ensures the coordinator and redirects the super admin to its chat (`/admin/bots/1` on a fresh install), anyone else to their latest bot, else their first workspace, else an empty page; `/admin/bots/[bot_id]` renders the conversation (header, thread and composer, the last two held to 760px; there is no right panel); `/admin/bots/[bot_id]/models` is a worker bot's own model settings, for super admins and admins (the coordinator's redirects to its chat); `/admin/workspaces/[workspace_id]` is a workspace's dashboard (`(sections)/workspace-dashboard.tsx`: read-only picture, name, description and context, latest activity, bots with their per-workspace instructions and memory, computers; its header links into `/admin/settings/workspaces/[workspace_id]`, whose `workspace-overview.tsx` has the editable groups) and `/admin/workspaces/[workspace_id]/bots/[bot_id]` is the same `Conversation` section scoped to the workspace (`pageProps.workspace`; only the coordinator and member bots). All render in the full-bleed `src/layouts/bots/` layout (rail of conversations + user popover), which `__root.tsx` uses for every `/admin/*` page.
|
||||
- **Server:** `src/functions/backend/db/bots/` (`grab-bots`, `grab-bot-messages`, `grab-active-bot-task`, `grab-user-bot`, `create-bot`, `insert-user-message`). `api/admin/bots/send-message.ts` posts a user message to a bot the session user chats with (zod: `src/schemas/bot-schema.ts`), queues the bot's reply (`enqueueBotReply`) and wakes the queue (`src/bots-queue/wake-bots-queue.ts`; never import `src/bots-queue/index.ts` from a page or API route, it pulls the bunext runtime into the page bundles). `upload-attachment.ts` stores one file for the next message (private `media` row, paradigm `bot-attachment`, type from `attachmentFileKind`, documents get `text_content`, limits in `AppData.BotAttachment*`) and `send-message` takes their `media_ids` (checked to be the user's own by `grabAttachableMedia`), linked in `bot_message_media`; messages carry `attachments` (`BotMessageWithAttachments`). `mark-read.ts` clears a session's unread when a message arrives in the open conversation; `session-messages.ts` returns a session's latest messages, which the open conversation polls every `AppData.BotReplyCatchUpMs` while a reply is due (`use-reply-catch-up`), so a reply whose live push was missed (socket down) still lands without a reload (replayed as a `server:bot-message`, so the rail and unread take it too); `create-bot.ts` adds a worker (and puts it in a workspace when given one); `save-harness-settings.ts` saves a worker bot's models.
|
||||
- **Composer:** a Tiptap editor (`(hooks)/use-composer-editor.ts`, `(partials)/composer-editor.tsx`), not a textarea, 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).
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
@@ -55,6 +55,13 @@ export const BotNeedsYouStatusLine = "Waiting on you";
|
||||
*/
|
||||
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
|
||||
* computer to finish.
|
||||
|
||||
@@ -104,10 +104,28 @@ export default function useComposerAttachments({ bot_id, workspace_id }: Params)
|
||||
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 {
|
||||
pending,
|
||||
add,
|
||||
remove,
|
||||
restore,
|
||||
clear: () => {
|
||||
revoke({ items: pending });
|
||||
setPending([]);
|
||||
|
||||
@@ -5,6 +5,7 @@ import BotAvatar from "@/src/components/general/bot-avatar";
|
||||
import type { BotHue } from "@/src/dict/bot-dict";
|
||||
import {
|
||||
BotComputerBusyStatusLine,
|
||||
BotGettingMessageStatusLine,
|
||||
BotReplyJobTitle,
|
||||
BotWaitingStatusLine,
|
||||
} 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.
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
@@ -31,28 +37,39 @@ type Props = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Under the last message while the bot is working on a reply or a task
|
||||
* (or waiting for a model or its computer), so slow work doesn't look like
|
||||
* none. A task or a reply shows its latest step. The bot's face is one per user,
|
||||
* shared by all their sessions with it, so a reply shows from the thread
|
||||
* alone: every user message queues one, so while the user has the last
|
||||
* word a reply is due here, even while the bot works elsewhere. Only the
|
||||
* waits (for a model, for its computer) come from the face.
|
||||
* Under the last message while the bot is getting a just-sent message,
|
||||
* working on a reply or a task (or waiting for a model or its computer), so
|
||||
* slow work doesn't look like none. A task or a reply shows its latest step.
|
||||
* The bot's face is one per user, shared by all their sessions with it, so a
|
||||
* reply shows from the thread alone: every user message queues one, so while
|
||||
* the user has the last word a reply is due here, even while the bot works
|
||||
* 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 asleep = bot.face == "asleep";
|
||||
const waiting_model = asleep && bot.status_line == BotWaitingStatusLine && Boolean(awaiting_reply || has_task);
|
||||
const waiting_computer =
|
||||
asleep && bot.status_line == BotComputerBusyStatusLine && Boolean(has_task) && !awaiting_reply;
|
||||
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 =
|
||||
!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 ${BotGettingMessageStatusLine.toLowerCase()}…`
|
||||
: thinking
|
||||
? `${name} is thinking…`
|
||||
: working
|
||||
? `${name} is working…`
|
||||
|
||||
@@ -4,6 +4,7 @@ import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import P from "@moduletrace/twui/layout/P";
|
||||
import type {
|
||||
BotExtensionsPayload,
|
||||
BotMessageAttachment,
|
||||
BotMessageWithAttachments,
|
||||
BotVoicePayload,
|
||||
BotWithLastMessage,
|
||||
@@ -55,7 +56,18 @@ type Props = {
|
||||
*/
|
||||
reply_to?: BotMessageWithAttachments | null;
|
||||
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;
|
||||
/**
|
||||
* It didn't make it: the pending copy goes.
|
||||
*/
|
||||
onSendFailed: (params: { temp_id: number }) => void;
|
||||
handle_ref?: Ref<ComposerHandle>;
|
||||
};
|
||||
|
||||
@@ -68,7 +80,9 @@ type Props = {
|
||||
* 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
|
||||
* 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({
|
||||
bot,
|
||||
@@ -77,12 +91,13 @@ export default function Composer({
|
||||
voice,
|
||||
reply_to,
|
||||
onCancelReply,
|
||||
onSendStart,
|
||||
onSent,
|
||||
onSendFailed,
|
||||
handle_ref,
|
||||
}: Props) {
|
||||
const draft = useComposerDraft({ bot_id: bot.id, workspace_id });
|
||||
const { text, setText } = draft;
|
||||
const [sending, setSending] = useState(false);
|
||||
const [error, setError] = useState<string>();
|
||||
const files = useComposerAttachments({ bot_id: bot.id, workspace_id });
|
||||
const online = useOnlineStatus();
|
||||
@@ -90,6 +105,16 @@ export default function Composer({
|
||||
const workspaces = useContext(CrewWorkspacesContext);
|
||||
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({
|
||||
placeholder: `Message ${bot.name}`,
|
||||
bots,
|
||||
@@ -144,35 +169,99 @@ export default function Composer({
|
||||
|
||||
const can_send =
|
||||
Boolean(text.trim() || files.media_ids.length) &&
|
||||
!sending &&
|
||||
!files.uploading &&
|
||||
recorder.state == "idle" &&
|
||||
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() {
|
||||
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);
|
||||
|
||||
const res = await sendBotMessage({
|
||||
temp_id.current -= 1;
|
||||
|
||||
const client_id = temp_id.current;
|
||||
const text_sent = text;
|
||||
const media_ids = files.media_ids;
|
||||
const attachments = files.pending.flatMap((item) =>
|
||||
item.status == "ready" && item.attachment ? [item.attachment] : [],
|
||||
);
|
||||
|
||||
onSendStart({
|
||||
message: {
|
||||
id: client_id,
|
||||
bot_id: bot.id,
|
||||
workspace_id,
|
||||
text,
|
||||
media_ids: files.media_ids,
|
||||
reply_to_message_id: reply_to?.id || null,
|
||||
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);
|
||||
|
||||
if (!res.success || !res.singleRes) {
|
||||
setError(res.msg || "Message not sent");
|
||||
return;
|
||||
}
|
||||
/**
|
||||
* The message takes the quote with it, so a new one typed while it
|
||||
* is on its way doesn't inherit it.
|
||||
*/
|
||||
onCancelReply?.();
|
||||
|
||||
setText("");
|
||||
input.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 });
|
||||
}
|
||||
|
||||
@@ -233,7 +322,7 @@ export default function Composer({
|
||||
) : null}
|
||||
<ComposerSendButton
|
||||
disabled={!can_send}
|
||||
loading={sending || files.uploading}
|
||||
loading={files.uploading}
|
||||
onClick={send}
|
||||
/>
|
||||
</Row>
|
||||
|
||||
@@ -21,6 +21,10 @@ type Props = {
|
||||
* The latest step of the reply running here, shown while it thinks.
|
||||
*/
|
||||
reply_progress?: string | null;
|
||||
/**
|
||||
* The last message is still on its way to the server.
|
||||
*/
|
||||
receiving?: boolean;
|
||||
/**
|
||||
* 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
|
||||
* 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 ref = useRef<HTMLDivElement>(null);
|
||||
const { selection, clear } = useMessageSelection({ ref });
|
||||
@@ -64,6 +76,7 @@ export default function MessageThread({ bot, messages, progress, reply_progress,
|
||||
progress={progress}
|
||||
reply_progress={reply_progress}
|
||||
awaiting_reply={messages[messages.length - 1]?.author == "user"}
|
||||
receiving={receiving}
|
||||
has_task={has_task}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
@@ -16,6 +16,7 @@ import { CrewWorkspacesContext } from "../(data)/crew-workspaces-context";
|
||||
import { BotMessageActionsContext } from "../(data)/message-actions-context";
|
||||
import { SpeakMessageContext } from "../(data)/speak-message-context";
|
||||
import reactToBotMessage from "../(functions)/react-to-bot-message";
|
||||
import dropSentMessage from "../(utils)/drop-sent-message";
|
||||
import useBotComputers from "../(hooks)/use-bot-computers";
|
||||
import useTaskProgress from "../(hooks)/use-task-progress";
|
||||
import useReplyProgress from "../(hooks)/use-reply-progress";
|
||||
@@ -45,6 +46,15 @@ export default function Conversation() {
|
||||
const [messages, setMessages] = useState<BotMessageWithAttachments[]>(
|
||||
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>(
|
||||
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(() => {
|
||||
setReplyTo(null);
|
||||
setPendingMessages([]);
|
||||
}, [session_id]);
|
||||
|
||||
const bots = useLiveBots({
|
||||
@@ -87,18 +99,25 @@ export default function Conversation() {
|
||||
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({
|
||||
bot_id: bot?.id || 0,
|
||||
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({
|
||||
session_id,
|
||||
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.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.author == "user" && startsNewSession(message)) {
|
||||
setSessionId(Number(message.session_id));
|
||||
@@ -207,9 +234,10 @@ export default function Conversation() {
|
||||
/>
|
||||
<MessageThread
|
||||
bot={live_bot}
|
||||
messages={messages}
|
||||
messages={thread_messages}
|
||||
progress={progress.text}
|
||||
reply_progress={reply_progress}
|
||||
receiving={pending_messages.length > 0}
|
||||
has_task={Boolean(progress.task_id)}
|
||||
onQuote={({ text }) =>
|
||||
composer.current?.insertQuote({ text })
|
||||
@@ -223,8 +251,28 @@ export default function Conversation() {
|
||||
voice={pageProps?.bot_voice}
|
||||
reply_to={reply_to}
|
||||
onCancelReply={() => setReplyTo(null)}
|
||||
onSendStart={({ message }) =>
|
||||
setPendingMessages((prev) => [
|
||||
...prev,
|
||||
message,
|
||||
])
|
||||
}
|
||||
onSendFailed={({ temp_id }) =>
|
||||
setPendingMessages((prev) =>
|
||||
prev.filter(
|
||||
(message) =>
|
||||
message.id != temp_id,
|
||||
),
|
||||
)
|
||||
}
|
||||
onSent={({ message }) => {
|
||||
setReplyTo(null);
|
||||
setPendingMessages((prev) =>
|
||||
dropSentMessage({
|
||||
pending: prev,
|
||||
message,
|
||||
}),
|
||||
);
|
||||
|
||||
if (
|
||||
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)];
|
||||
}
|
||||
Reference in New Issue
Block a user