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
@@ -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,32 +37,43 @@ 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
? `${name} is thinking…`
: working
? `${name} is working…`
: `${bot.status_line}…`;
const label = getting
? `${name} is ${BotGettingMessageStatusLine.toLowerCase()}…`
: thinking
? `${name} is thinking…`
: working
? `${name} is working…`
: `${bot.status_line}…`;
const detail = working ? progress : thinking ? reply_progress : null;
return (
@@ -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({
bot_id: bot.id,
workspace_id,
text,
media_ids: files.media_ids,
reply_to_message_id: reply_to?.id || null,
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: 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>