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,48 @@
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
import type { APIResponseObject } from "@moduletrace/bunext/types";
import { useEffect, useState } from "react";
import useServerEvent from "@/src/hooks/use-server-event";
import type { ChatroomSummary } from "@/src/types";
import chatroomInPlace from "@/src/utils/chatroom-in-place";
import mergeChatroom from "@/src/utils/merge-chatroom";
type Params = {
/**
* The workspace whose rooms the rail lists, or (null) those asked from
* bots' own chats.
*/
workspace_id: number | null;
};
/**
* One place's latest chatrooms for the rail: loaded by the rail itself (no
* page carries them), then kept current from `server:chatroom-updated`,
* which only adds rooms of that place. Null until the first load.
*/
export default function useRailChatrooms({ workspace_id }: Params) {
const [chatrooms, setChatrooms] = useState<ChatroomSummary[] | null>(null);
useEffect(() => {
setChatrooms(null);
fetchApi<{}, APIResponseObject<ChatroomSummary>>(
`/api/admin/chatrooms/list${workspace_id ? `?workspace_id=${workspace_id}` : ""}`,
{ method: "GET" },
)
.then((res) => {
if (res?.success) setChatrooms(res.payload || []);
})
.catch(() => {});
}, [workspace_id]);
useServerEvent({
event: "server:chatroom-updated",
onData: (room: ChatroomSummary) => {
if (!chatroomInPlace({ chatroom: room, workspace_id })) return;
setChatrooms((prev) => mergeChatroom({ chatrooms: prev || [], chatroom: room }));
},
});
return chatrooms;
}
@@ -0,0 +1,47 @@
import Link from "@moduletrace/twui/layout/Link";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import { twMerge } from "tailwind-merge";
import ChatroomStone from "@/src/components/general/chatroom-stone";
import type { ChatroomSummary } from "@/src/types";
import chatroomStatusLine from "@/src/utils/chatroom-status-line";
type Props = {
chatroom: ChatroomSummary;
href: string;
selected?: boolean;
};
/**
* A chatroom in the rail, the same 48px row as a bot: the asking bot's
* stone, the request and where it stands. Unread is said in words.
*/
export default function RailChatroomRow({ chatroom, href, selected }: Props) {
const requester = chatroom.bots[0];
return (
<Link
href={href}
aria-current={selected ? "page" : undefined}
title={chatroom.title || "Chatroom"}
className={twMerge(
"flex shrink-0 h-12 items-center gap-3 rounded-default px-1 @rail:px-3 border-0 leading-normal text-ink",
"hover:opacity-100 hover:bg-sunken transition-colors duration-[var(--m-fast)]",
"focus-visible:ring-2 focus-visible:ring-ink focus-visible:ring-offset-2 focus-visible:ring-offset-paper",
selected && "bg-ink/5 hover:bg-ink/5",
)}
>
<Span className="mx-auto @rail:mx-0 inline-flex shrink-0">
{requester ? <ChatroomStone bot={requester} size={28} /> : null}
</Span>
<Stack className="hidden @rail:flex grow min-w-0 gap-0!">
<Span className="text-sm font-bold leading-5 text-ink truncate w-full">
{chatroom.title || "A request"}
</Span>
<Span className="text-xs leading-4 text-ink-secondary truncate w-full">
{chatroom.unread ? "New messages" : chatroomStatusLine(chatroom)}
</Span>
</Stack>
</Link>
);
}
@@ -0,0 +1,68 @@
import { useContext } from "react";
import Link from "@moduletrace/twui/layout/Link";
import Span from "@moduletrace/twui/layout/Span";
import { Ellipsis } from "lucide-react";
import { AppData } from "@/src/data/app-data";
import { AppContext } from "@/src/pages/__root";
import useRailChatrooms from "../(hooks)/use-rail-chatrooms";
import chatroomHref from "@/src/utils/chatroom-href";
import RailChatroomRow from "./rail-chatroom-row";
import RailSectionLabel from "./rail-section-label";
type Props = {
/**
* Inside a workspace, its rooms; left out, those asked from bots' own
* chats.
*/
workspace_id?: number | null;
};
/**
* One place's chatrooms (bots' own chats after the workspaces, or a
* workspace's after its bots): the latest `AppData.ChatroomRailLimit`
* (open ones first), then "See all chatrooms" for the page that lists
* them. Loaded by the rail, not the page.
*/
export default function RailChatroomsSection({ workspace_id = null }: Props) {
const { pageProps } = useContext(AppContext);
const chatrooms = useRailChatrooms({ workspace_id });
const see_all = workspace_id ? `/admin/workspaces/${workspace_id}/chatrooms` : "/admin/chatrooms";
const selected_id = pageProps?.chatroom?.id;
if (!chatrooms) return null;
const shown = chatrooms.slice(0, AppData.ChatroomRailLimit);
return (
<>
<RailSectionLabel title="Chatrooms" />
{shown.map((chatroom) => (
<RailChatroomRow
key={chatroom.id}
chatroom={chatroom}
href={chatroomHref({ chatroom })}
selected={chatroom.id == selected_id}
/>
))}
{chatrooms.length ? null : (
<Span className="hidden @rail:block px-3 py-2 text-[13px] leading-[18px] text-ink-secondary">
{workspace_id
? "No chatrooms here yet. When a bot here needs another bot here, it asks the coordinator, and the exchange is kept here."
: "No chatrooms yet. When a bot needs another bot, it asks the coordinator, and the exchange is kept here."}
</Span>
)}
{chatrooms.length ? (
<Link
href={see_all}
title="See all chatrooms"
className="flex shrink-0 h-10 w-full items-center justify-center @rail:justify-start gap-3 rounded-default px-1 @rail:px-3 border-0 text-ink-secondary hover:opacity-100 hover:text-ink hover:bg-sunken transition-colors duration-[var(--m-fast)] focus-visible:ring-2 focus-visible:ring-ink"
>
<Ellipsis size={18} aria-hidden className="@rail:hidden" />
<Span className="hidden @rail:inline text-[13px] font-bold leading-4 truncate">
See all chatrooms
</Span>
</Link>
) : null}
</>
);
}
@@ -9,6 +9,7 @@ import RailBotSessions from "./rail-bot-sessions";
import RailBotsSection from "./rail-bots-section";
import RailSectionAddButton from "./rail-section-add-button";
import RailWorkspacesSection from "./rail-workspaces-section";
import RailChatroomsSection from "./rail-chatrooms-section";
type Props = {
bots: BotWithLastMessage[];
@@ -18,7 +19,8 @@ type Props = {
/**
* The rail outside workspaces: the pinned coordinator, the crew (the
* latest few, then "See all bots"), then the workspaces.
* latest few, then "See all bots"), then the workspaces, then the
* chatrooms.
*/
export default function RailCrewList({ bots, workspaces, selected_id }: Props) {
const { openForm } = useContext(AddFlowContext);
@@ -53,6 +55,7 @@ export default function RailCrewList({ bots, workspaces, selected_id }: Props) {
}
/>
<RailWorkspacesSection workspaces={workspaces} />
<RailChatroomsSection />
</>
);
}
@@ -9,6 +9,7 @@ import RailBotSessions from "./rail-bot-sessions";
import RailBotsSection from "./rail-bots-section";
import RailSectionAddButton from "./rail-section-add-button";
import WorkspaceRow from "./workspace-row";
import RailChatroomsSection from "./rail-chatrooms-section";
type Props = {
workspace: WorkspaceWithActivity;
@@ -26,8 +27,9 @@ type Props = {
/**
* The rail inside a workspace: back to all bots, the workspace itself (its
* page), the coordinator working in it, then the workspace's bots. Every
* conversation here runs with the workspace's context.
* page), the coordinator working in it, the workspace's bots, then the
* chatrooms asked from here. Every conversation here runs with the
* workspace's context.
*/
export default function RailWorkspaceList({
workspace,
@@ -84,6 +86,7 @@ export default function RailWorkspaceList({
hrefFor={(bot) => `${base}/bots/${bot.id}`}
empty="No bots here yet."
/>
<RailChatroomsSection workspace_id={workspace.id ? Number(workspace.id) : null} />
</>
);
}
+21 -7
View File
@@ -31,10 +31,14 @@ export default function BotsRail() {
const pathname = pageProps?.url?.pathname;
const is_settings_page = Boolean(pathname?.startsWith("/admin/settings"));
const is_rail_page = RailPages.some(
(page) => pathname == page.url || Boolean(pathname?.startsWith(`${page.url}/`)),
(page) =>
pathname == page.url ||
Boolean(pathname?.startsWith(`${page.url}/`)),
);
const workspace = pageProps?.workspace;
const in_workspace = Boolean(workspace?.id && pathname?.startsWith("/admin/workspaces/"));
const in_workspace = Boolean(
workspace?.id && pathname?.startsWith("/admin/workspaces/"),
);
const bots = useLiveBots({
bots: pageProps?.bots || [],
@@ -43,7 +47,9 @@ export default function BotsRail() {
});
const workspaces = useLiveWorkspaces({
workspaces: pageProps?.workspaces || [],
open_workspace_id: in_workspace ? workspace?.id || undefined : undefined,
open_workspace_id: in_workspace
? workspace?.id || undefined
: undefined,
});
const live_workspace = workspaces.find((w) => w.id == workspace?.id);
@@ -57,10 +63,18 @@ export default function BotsRail() {
/>
) : null}
<Stack
className={`@container fixed inset-y-0 left-0 z-40 w-[280px] max-w-[85vw] shrink-0 h-full gap-0! items-stretch bg-paper border-0 border-r border-solid border-hairline shadow-popover transition-transform duration-[var(--m-base)] pt-[env(safe-area-inset-top)] pb-[env(safe-area-inset-bottom)] md:static md:z-auto md:w-16 md:max-w-none md:shadow-none md:pt-0 md:pb-0 lg:w-[260px] ${
open ? "translate-x-0" : "-translate-x-full md:translate-x-0"
className={`@container fixed inset-y-0 left-0 z-40 w-[280px] max-w-[85vw] shrink-0 h-full gap-0! items-stretch bg-paper border-0 border-r border-solid border-hairline shadow-popover transition-transform duration-[var(--m-base)] pt-[env(safe-area-inset-top)] pb-[env(safe-area-inset-bottom)] md:static md:z-auto md:w-16 md:max-w-none md:shadow-none md:pt-0 md:pb-0 xl:w-[320px] ${
open
? "translate-x-0"
: "-translate-x-full md:translate-x-0"
}`}
aria-label={is_settings_page ? "Settings" : in_workspace ? workspace?.name || "Workspace" : "Bots"}
aria-label={
is_settings_page
? "Settings"
: in_workspace
? workspace?.name || "Workspace"
: "Bots"
}
>
<RailHeader show_add={!is_settings_page && !is_rail_page} />
<Stack className="grow min-h-0 overflow-y-auto gap-0! items-stretch px-2 @rail:px-3 pt-1 pb-3">
@@ -73,7 +87,7 @@ export default function BotsRail() {
workspace={live_workspace}
bots={bots}
selected_id={pageProps?.bot?.id || undefined}
on_workspace_page={!pageProps?.bot?.id}
on_workspace_page={pathname == `/admin/workspaces/${workspace?.id}`}
/>
) : (
<RailCrewList