Only offer reply and reactions on bot messages

The user's own messages no longer get the reply and react row. A bot's
reaction to one still shows, read-only, and react-message refuses a
reaction on the user's own message.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-19 22:04:29 +01:00
co-authored by Claude Opus 5.5
parent d1b5b6d68a
commit 100ef0eb89
5 changed files with 40 additions and 17 deletions
+1 -1
View File
@@ -92,7 +92,7 @@ Inspect live data with `bun llm-code-assistant/query-db.ts "<sql>"` - a read-onl
- **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.
- **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). User messages stay plain text.
- **Replies and reactions:** every message has a row under it (`(partials)/message-actions.tsx`, handlers from `BotMessageActionsContext`) to reply or react. 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 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.
- **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).
@@ -3,14 +3,34 @@ import Span from "@moduletrace/twui/layout/Span";
import type { BotMessageReaction } from "@/src/types";
type Props = BotMessageReaction & {
onToggle: () => void;
/**
* Left out on the user's own messages: the bot's reactions there only
* show.
*/
onToggle?: () => void;
};
/**
* One emoji reaction under a message: its count, highlighted when the
* user gave it. Clicking takes it back or adds it.
*/
export default function MessageReactionChip({ emoji, count, mine, onToggle }: Props) {
export default function MessageReactionChip({
emoji,
count,
mine,
onToggle,
}: Props) {
if (!onToggle) {
return (
<Span className="inline-flex shrink-0 items-center gap-1 px-2 py-0.5 rounded-default-lg border border-solid border-hairline bg-surface text-xs leading-4 text-ink-secondary">
<Span className="text-sm leading-4">{emoji}</Span>
{count > 1 ? (
<Span className="tabular-nums">{count}</Span>
) : null}
</Span>
);
}
return (
<Button
title={mine ? "Remove your reaction" : "Add this reaction"}
@@ -20,7 +40,7 @@ export default function MessageReactionChip({ emoji, count, mine, onToggle }: Pr
variant="ghost"
color="text"
onClick={onToggle}
className={`shrink-0 gap-1! px-2! py-0.5! rounded-default-lg border border-solid text-xs leading-4 ${
className={`shrink-0 gap-1! p-0! rounded-default-lg text-xs leading-4 ${
mine
? "border-hairline-strong bg-sunken text-ink"
: "border-hairline bg-surface text-ink-secondary hover:text-ink"
@@ -6,11 +6,15 @@ import MessageReactionChip from "./message-reaction-chip";
type Props = {
message_id: number;
reactions?: BotMessageReaction[];
onReact: (params: { message_id: number; emoji: BotMessageReactionEmoji }) => void;
/**
* Left out where the reactions only show (the user's own messages).
*/
onReact?: (params: { message_id: number; emoji: BotMessageReactionEmoji }) => void;
};
/**
* The reactions a message has, one chip per emoji, each toggleable.
* The reactions a message has, one chip per emoji, each toggleable when
* `onReact` is given.
*/
export default function MessageReactions({ message_id, reactions, onReact }: Props) {
if (!reactions?.length) return null;
@@ -23,7 +27,7 @@ export default function MessageReactions({ message_id, reactions, onReact }: Pro
emoji={reaction.emoji}
count={reaction.count}
mine={reaction.mine}
onToggle={() => onReact({ message_id, emoji: reaction.emoji })}
onToggle={onReact ? () => onReact({ message_id, emoji: reaction.emoji }) : undefined}
/>
))}
</Row>
@@ -1,8 +1,5 @@
import { useContext } from "react";
import Stack from "@moduletrace/twui/layout/Stack";
import type { BotMessageWithAttachments } from "@/src/types";
import { BotMessageActionsContext } from "../(data)/message-actions-context";
import MessageActions from "./message-actions";
import MessageAttachments from "./message-attachments";
import MessageReactions from "./message-reactions";
import MessageReplyQuote from "./message-reply-quote";
@@ -15,11 +12,10 @@ type Props = {
/**
* One user message: the quoted message it answers, its files, then its
* text (a file-only message has no bubble), its reactions and actions.
* text (a file-only message has no bubble) and the bot's reactions to it.
* The user doesn't reply to or react to their own messages.
*/
export default function UserMessage({ message, bot_name }: Props) {
const { onReact } = useContext(BotMessageActionsContext);
return (
<Stack className="w-full min-w-0 max-w-full items-end gap-1.5!">
{message.reply_to ? (
@@ -35,10 +31,8 @@ export default function UserMessage({ message, bot_name }: Props) {
<MessageReactions
message_id={message.id}
reactions={message.reactions}
onReact={onReact}
/>
) : null}
<MessageActions message={message} />
</Stack>
);
}
+7 -2
View File
@@ -10,8 +10,8 @@ import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
import safeErrorMessage from "@/src/utils/safe-error-message";
/**
* Adds or takes back one of the fixed emoji reactions on a message in the
* session user's own conversation, and pushes the message's fresh
* Adds or takes back one of the fixed emoji reactions on a bot message in
* the session user's own conversation, and pushes the message's fresh
* reactions to their other open tabs.
*/
export const handler: BunextAPIRouteHandler<APIResponseObject> = async ({ req, body }) => {
@@ -36,6 +36,11 @@ export const handler: BunextAPIRouteHandler<APIResponseObject> = async ({ req, b
});
const message = await grabUserBotMessage({ user_id: user.id, message_id });
if (message.author == "user") {
throw new AppError(`You can only react to the bot's messages.`);
}
const reactions = await toggleMessageReaction({
message_id: Number(message.id),
user_id: user.id,