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:
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user