Let workers ask each other through the coordinator in chatrooms

A worker asks for another bot's help with ask_crew. The coordinator
reads the request, asks the right bot (in a workspace, only its
members), and sends the answer back into the asking bot's
conversation, waking it. Each exchange is kept as a read-only
chatroom: listed in the main rail after the workspaces, or in the
workspace's rail for requests made there, with live status rows for
each bot while it's open and a Stop button.

Room sessions are hidden from every conversation list, other bots'
words reach the model fenced as data, and turns, nesting and open
rooms are capped. Closed rooms are pruned after a retention period
and past a cap per place (Settings > General).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-27 18:57:34 +01:00
co-authored by Claude Opus 5.5
parent 2d3c15e48b
commit 7b762fd384
139 changed files with 4246 additions and 64 deletions
@@ -0,0 +1,24 @@
import Link from "@moduletrace/twui/layout/Link";
import { MessagesSquare } from "lucide-react";
type Props = {
chatroom_id: number;
};
/**
* A chatroom named in a message (`chatroomRef`): a chip that opens the
* room, where the request and everything said about it is kept (a
* workspace's room is sent on to its page inside the workspace).
*/
export default function ChatroomRefChip({ chatroom_id }: Props) {
return (
<Link
href={`/admin/chatrooms/${chatroom_id}`}
title="Open the chatroom"
className="inline-flex h-6 gap-1.5 items-center px-1.5 align-middle rounded-default-xs border-0 bg-sunken text-[13px] font-bold leading-4 text-ink whitespace-nowrap hover:opacity-80"
>
<MessagesSquare size={13} aria-hidden />
{`Chatroom ${chatroom_id}`}
</Link>
);
}
@@ -0,0 +1,35 @@
import Paper from "@moduletrace/twui/elements/Paper";
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import BotStone from "@/src/components/general/bot-stone";
import type { MessageGroup } from "../(utils)/group-messages";
import CrewMessageLine from "./crew-message-line";
type Props = {
group: MessageGroup;
};
/**
* What the coordinator brought back from a chatroom, on its own paper so
* it stands apart from the conversation: text under the hue-less
* coordinator stone, never a user bubble, though the bot answers it like
* one.
*/
export default function CrewMessageGroup({ group }: Props) {
return (
<Paper className="w-full p-4 gap-0! items-stretch rounded-default bg-surface dark:bg-surface border-hairline dark:border-hairline">
<Row className="w-full gap-3! flex-nowrap items-start">
<BotStone size={28} label="" className="-mt-1 shrink-0" />
<Stack className="min-w-0 grow gap-1! items-start">
<Span className="text-[11px] font-bold uppercase tracking-wide leading-4 text-ink-secondary">
From the coordinator
</Span>
{group.messages.map((message) => (
<CrewMessageLine key={message.id} message={message} />
))}
</Stack>
</Row>
</Paper>
);
}
@@ -0,0 +1,33 @@
import { memo } from "react";
import Markdown from "react-markdown";
import remarkGfm from "remark-gfm";
import Stack from "@moduletrace/twui/layout/Stack";
import type { BotMessageWithAttachments } from "@/src/types";
import linkComputerRefs from "@/src/utils/link-computer-refs";
import { MessageMarkdownComponents } from "../(data)/message-markdown-components";
/**
* Kept module-level: a new array on every render makes `react-markdown`
* parse the message again.
*/
const RemarkPlugins = [remarkGfm];
type Props = {
message: BotMessageWithAttachments;
};
/**
* One chatroom outcome in the asking bot's conversation, rendered like a
* bot's message (its room ref a chip that opens the room).
*/
function CrewMessageLine({ message }: Props) {
return (
<Stack className="w-full gap-2.5! items-start break-words">
<Markdown remarkPlugins={RemarkPlugins} components={MessageMarkdownComponents}>
{linkComputerRefs({ text: message.text || "" })}
</Markdown>
</Stack>
);
}
export default memo(CrewMessageLine);
@@ -14,7 +14,9 @@ import {
} from "@/src/utils/link-computer-refs";
import { parseFolderRefHref } from "@/src/utils/folder-ref";
import { graphicRefId } from "@/src/utils/graphic-ref";
import { chatroomRefId } from "@/src/utils/chatroom-ref";
import BotRefChip from "./bot-ref-chip";
import ChatroomRefChip from "./chatroom-ref-chip";
import ComputerRefChip from "./computer-ref-chip";
import FileRefChip from "./file-ref-chip";
import FolderRefChip from "./folder-ref-chip";
@@ -34,7 +36,8 @@ import WorkspaceRefChip from "./workspace-ref-chip";
* folder it named a chip that opens it in the files browser, an MCP server,
* tool, skill or hook it proposed a chip that opens the confirm card, a
* secret it showed a masked chip, a secret it asked for a chip that opens
* the card it's typed into, a graphic it made the graphic itself;
* the card it's typed into, a graphic it made the graphic itself, a
* chatroom a chip that opens it;
* anything else opens in a new tab.
*/
export default function MarkdownLink({ href, children }: ComponentProps<"a">) {
@@ -101,6 +104,10 @@ export default function MarkdownLink({ href, children }: ComponentProps<"a">) {
if (request_id) return <SecretRequestRefChip request_id={request_id} />;
}
const chatroom_id = chatroomRefId({ href });
if (chatroom_id) return <ChatroomRefChip chatroom_id={chatroom_id} />;
const graphic_id = graphicRefId({ href });
if (graphic_id) return <GraphicRefCard graphic_id={graphic_id} />;
@@ -3,6 +3,7 @@ import type { MessageGroup } from "../(utils)/group-messages";
import UserMessageGroup from "./user-message-group";
import BotMessageGroup from "./bot-message-group";
import SessionDivider from "./session-divider";
import CrewMessageGroup from "./crew-message-group";
type Props = {
bot: BotWithLastMessage;
@@ -15,7 +16,9 @@ type Props = {
*/
export default function MessageGroupBlock({ bot, group }: Props) {
const block =
group.author === "user" ? (
group.author === "crew" ? (
<CrewMessageGroup group={group} />
) : group.author === "user" ? (
<UserMessageGroup bot={bot} group={group} />
) : (
<BotMessageGroup bot={bot} group={group} />
@@ -272,11 +272,11 @@ export default function Conversation() {
* rollover whose summary push was missed): the thread
* moves there, and loads it so its opening shows too.
*/
if (message.author == "user" && startsNewSession(message)) {
if (message.author == "user" && !message.from_chatroom_id && startsNewSession(message)) {
setSessionId(Number(message.session_id));
setMessages([message]);
history.retry();
} else if (message.author == "user") {
} else if (message.author == "user" && !message.from_chatroom_id) {
window.location.reload();
} else {
setHasOtherUnread(true);
@@ -47,4 +47,14 @@ describe("groupMessages", () => {
[2, true],
]);
});
test("a chatroom's outcome is its own group, never the user's", () => {
const groups = groupMessages({
messages: [
{ id: 1, author: "user", text: "Ask Ada", created_at: 1, session_id: 1 },
{ id: 2, author: "user", text: "Answer", created_at: 2, session_id: 1, from_chatroom_id: 4 },
],
});
expect(groups.map((group) => group.author)).toEqual(["user", "crew"]);
});
});
@@ -2,7 +2,11 @@ import type { BotMessageWithAttachments } from "@/src/types";
export type MessageGroup = {
key: string;
author: "bot" | "user";
/**
* `crew`: a chatroom's outcome brought back to the bot (`from_chatroom_id`),
* stored on the user's side but shown as from the coordinator.
*/
author: "bot" | "user" | "crew";
messages: BotMessageWithAttachments[];
/**
* Time of the group's last message, shown once per group.
@@ -34,7 +38,7 @@ export default function groupMessages({
const groups: MessageGroup[] = [];
for (const message of messages) {
const author = message.author === "user" ? "user" : "bot";
const author = message.from_chatroom_id ? "crew" : message.author === "user" ? "user" : "bot";
const at = Number(message.created_at || 0);
const last = groups[groups.length - 1];