diff --git a/server/index.test.ts b/server/index.test.ts index 3819f5b63..a4c21925d 100644 --- a/server/index.test.ts +++ b/server/index.test.ts @@ -317,6 +317,89 @@ describe("harness HTTP API", () => { expect(body.bots[0].messages.length).toBeGreaterThanOrEqual(2); }); + it("adds and removes room members through PATCH", async () => { + const [first, second, third] = await Promise.all([ + api("POST", "/api/bots"), + api("POST", "/api/bots"), + api("POST", "/api/bots"), + ]).then((created) => created.map((response) => response.body.bot)); + const room = (await api("POST", "/api/groups", { name: "Roster", memberIds: [first.id, second.id] })).body.group; + try { + const added = await api("PATCH", `/api/groups/${room.id}`, { memberIds: [first.id, second.id, third.id] }); + expect(added.status).toBe(200); + expect(added.body.group.memberIds).toEqual([first.id, second.id, third.id]); + + const removed = await api("PATCH", `/api/groups/${room.id}`, { memberIds: [third.id] }); + expect(removed.status).toBe(200); + expect(removed.body.group.memberIds).toEqual([third.id]); + + const state = (await api("GET", "/api/bots")).body; + expect(state.groups.find((group: { id: string }) => group.id === room.id).memberIds).toEqual([third.id]); + } finally { + await api("DELETE", `/api/groups/${room.id}`); + for (const bot of [first, second, third]) await api("DELETE", `/api/bots/${bot.id}`); + } + }); + + it("refuses to empty a room's roster", async () => { + const bot = (await api("POST", "/api/bots")).body.bot; + const room = (await api("POST", "/api/groups", { name: "Never empty", memberIds: [bot.id] })).body.group; + try { + for (const memberIds of [[], ["no-such-bot"]]) { + const attempted = await api("PATCH", `/api/groups/${room.id}`, { memberIds }); + expect(attempted.status).toBe(400); + expect(attempted.body.error).toMatch(/at least one bot/i); + } + const state = (await api("GET", "/api/bots")).body; + expect(state.groups.find((group: { id: string }) => group.id === room.id).memberIds).toEqual([bot.id]); + } finally { + await api("DELETE", `/api/groups/${room.id}`); + await api("DELETE", `/api/bots/${bot.id}`); + } + }); + + it("deduplicates repeated room members while preserving their first-seen order", async () => { + const [first, second] = await Promise.all([api("POST", "/api/bots"), api("POST", "/api/bots")]).then( + (created) => created.map((response) => response.body.bot), + ); + const room = (await api("POST", "/api/groups", { name: "Unique roster", memberIds: [first.id] })).body.group; + try { + const patched = await api("PATCH", `/api/groups/${room.id}`, { + memberIds: [second.id, first.id, second.id, first.id], + }); + expect(patched.status).toBe(200); + expect(patched.body.group.memberIds).toEqual([second.id, first.id]); + } finally { + await api("DELETE", `/api/groups/${room.id}`); + for (const bot of [first, second]) await api("DELETE", `/api/bots/${bot.id}`); + } + }); + + it("keeps direct-message channels a fixed pair at the API boundary", async () => { + const attempted = await api("PATCH", "/api/groups/test-dm", { memberIds: ["test-bot-a"] }); + expect(attempted.status).toBe(400); + expect(attempted.body.error).toMatch(/direct-message.*members/i); + const state = await api("GET", "/api/bots"); + const dm = state.body.groups.find((group: { id: string }) => group.id === "test-dm"); + expect(dm.memberIds).toEqual(["test-bot-a", "test-bot-b"]); + }); + + it("hands the lead to a remaining member when the lead leaves the room", async () => { + const [lead, other] = await Promise.all([api("POST", "/api/bots"), api("POST", "/api/bots")]).then((created) => + created.map((response) => response.body.bot), + ); + const room = (await api("POST", "/api/groups", { name: "Handover", memberIds: [lead.id, other.id] })).body.group; + try { + expect(room.defaultResponder).toEqual({ kind: "member", botId: lead.id }); + const patched = await api("PATCH", `/api/groups/${room.id}`, { memberIds: [other.id] }); + expect(patched.status).toBe(200); + expect(patched.body.group.defaultResponder).toEqual({ kind: "member", botId: other.id }); + } finally { + await api("DELETE", `/api/groups/${room.id}`); + for (const bot of [lead, other]) await api("DELETE", `/api/bots/${bot.id}`); + } + }); + it("keeps direct-message channels folderless at the API boundary", async () => { const attempted = await api("PATCH", "/api/groups/test-dm", { cwd: home }); expect(attempted.status).toBe(400); diff --git a/server/index.ts b/server/index.ts index 459ec7177..1064f9427 100644 --- a/server/index.ts +++ b/server/index.ts @@ -3121,8 +3121,15 @@ const server = createServer(async (req, res) => { if (body[key] !== undefined) patch[key] = body[key]; } if (Array.isArray(body.memberIds)) { - const ids = body.memberIds.filter((id: unknown): id is string => typeof id === "string" && Boolean(store.bot(id))); - if (ids.length) patch.memberIds = ids; + // A DM is the pair it was opened for; only real rooms have a roster. + if (existing.dm) return json(res, 400, { error: "direct-message channels cannot change members" }); + const ids = [ + ...new Set( + body.memberIds.filter((id: unknown): id is string => typeof id === "string" && Boolean(store.bot(id))), + ), + ]; + if (!ids.length) return json(res, 400, { error: "a room needs at least one bot" }); + patch.memberIds = ids; } if (body.defaultResponder !== undefined) { const value = body.defaultResponder as { kind?: unknown; botId?: unknown } | null; diff --git a/src/components/BotPickerList.tsx b/src/components/BotPickerList.tsx new file mode 100644 index 000000000..2efb0efcb --- /dev/null +++ b/src/components/BotPickerList.tsx @@ -0,0 +1,46 @@ +// The checkbox roster shared by "New Room" and "Manage Members": one row per +// bot, a tick on the ones picked. Both callers own their own selection state — +// this only draws it, so the two lists can never drift apart visually. +import { Check } from "lucide-react"; +import type { Bot } from "@/state/store"; +import { BotAvatar } from "./Avatar"; +import { cn } from "@/lib/cn"; + +export function BotPickerList({ + bots, + picked, + onToggle, + emptyHint, +}: { + bots: Bot[]; + picked: Set; + onToggle: (id: string) => void; + /** shown in place of the list when there is nothing to pick from */ + emptyHint: string; +}) { + return ( +
+ {bots.length === 0 &&
{emptyHint}
} + {bots.map((b) => ( + + ))} +
+ ); +} diff --git a/src/components/GroupView.tsx b/src/components/GroupView.tsx index a28a02ee1..f6babcb2a 100644 --- a/src/components/GroupView.tsx +++ b/src/components/GroupView.tsx @@ -3,7 +3,7 @@ // does not become a wall of competing motion. Plain messages go to the room's // default responder; @mentions override that routing. import { memo, useCallback, useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; -import { ArrowDown, ChevronDown, Folder, FolderOpen, Pin, PinOff, X } from "lucide-react"; +import { ArrowDown, ChevronDown, Folder, FolderOpen, Pin, PinOff, Plus, X } from "lucide-react"; import { api, useStore, @@ -23,6 +23,7 @@ import { ConnectorCard } from "./ConnectorCard"; import { GroupCallButton, GroupCallOverlay } from "./GroupCallView"; import { ReactionBar, ReactionChips } from "./Reactions"; import { ApprovalCard } from "./ApprovalCard"; +import { ManageMembersPanel } from "./ManageMembersPanel"; import { useDesktopCapabilities } from "./DesktopCapabilities"; import { cn } from "@/lib/cn"; import { useFocusMessage } from "@/lib/focus-message"; @@ -358,6 +359,9 @@ export function GroupView({ group }: { group: Group }) { const [bulletinOpen, setBulletinOpen] = useState(false); const [bulletinDraft, setBulletinDraft] = useState(group.bulletin); const [folderOpen, setFolderOpen] = useState(false); + const [membersOpen, setMembersOpen] = useState(false); + const membersTriggerRef = useRef(null); + const closeMembers = useCallback(() => setMembersOpen(false), []); const members = useMemo( () => group.memberIds.map((id) => state.bots.find((b) => b.id === id)).filter((b): b is Bot => Boolean(b)), @@ -415,6 +419,7 @@ export function GroupView({ group }: { group: Group }) { useEffect(() => setBulletinDraft(group.bulletin), [group.id, group.bulletin]); // an open folder editor belongs to the room it was opened in useEffect(() => setFolderOpen(false), [group.id]); + useEffect(() => setMembersOpen(false), [group.id]); // deps track the FULL messages.length, so expanding the window (which only // changes windowedMessages) can never re-trigger this bottom scrollTo useEffect(() => { @@ -464,6 +469,23 @@ export function GroupView({ group }: { group: Group }) { } }; + // Static mauses: one per member, a ring + dot on whoever is working. + const memberMauses = members.map((b) => ( + + + {group.busyBotId === b.id && ( + + )} + + )); + const isWin = window.ogb?.platform === "win32"; const drag = isWin ? ({ WebkitAppRegion: "drag" } as React.CSSProperties) : undefined; const noDrag = isWin ? ({ WebkitAppRegion: "no-drag" } as React.CSSProperties) : undefined; @@ -471,6 +493,9 @@ export function GroupView({ group }: { group: Group }) { return (
+ {membersOpen && !group.dm && ( + + )} {/* Header: static member mauses; a ring + dot marks the working bot. */}
{!group.dm && setFolderOpen((open) => !open)} />} {!group.dm && } - {members.map((b) => ( - setMembersOpen(true)} + title="Manage members" + aria-label={`Manage members — ${members.length} ${members.length === 1 ? "bot" : "bots"} in this room`} + className="flex items-center gap-1.5 rounded-full py-0.5 pl-1 pr-1.5 hover:bg-raised/60" > - - {group.busyBotId === b.id && ( - - )} - - ))} + {memberMauses} + + + + + )}
diff --git a/src/components/ManageMembersPanel.tsx b/src/components/ManageMembersPanel.tsx new file mode 100644 index 000000000..dcd3f69b8 --- /dev/null +++ b/src/components/ManageMembersPanel.tsx @@ -0,0 +1,141 @@ +// Edit an existing room's roster: the same picker "New Room" uses, opened +// from the member mauses in the room header and pre-ticked with who is +// already in. Membership is the only thing this touches — the transcript +// keeps every message a departing bot already sent. +import { useEffect, useMemo, useRef, useState, type RefObject } from "react"; +import { track } from "@/lib/analytics"; +import { useStore, type Group } from "@/state/store"; +import { BotPickerList } from "./BotPickerList"; +import { nextMemberIds } from "@/lib/room-members"; + +export function ManageMembersPanel({ + group, + onClose, + triggerRef, +}: { + group: Group; + onClose: () => void; + triggerRef: RefObject; +}) { + const { state, dispatch } = useStore(); + const [picked, setPicked] = useState>(() => new Set(group.memberIds)); + const [saveError, setSaveError] = useState(null); + const openedMemberIds = useRef([...group.memberIds]); + const dialogRef = useRef(null); + + // Archived bots stay listed while they are still members — otherwise a + // room could keep a member you have no way to remove. + const bots = useMemo( + () => state.bots.filter((b) => !b.hidden || group.memberIds.includes(b.id)), + [state.bots, group.memberIds], + ); + + useEffect(() => { + const dialog = dialogRef.current; + if (!dialog) return; + const focusable = () => + [...dialog.querySelectorAll('button:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex="-1"])')].filter( + (element) => !element.hasAttribute("hidden"), + ); + focusable()[0]?.focus(); + const onKey = (event: KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); + onClose(); + return; + } + if (event.key !== "Tab") return; + const controls = focusable(); + if (!controls.length) return event.preventDefault(); + const first = controls[0]; + const last = controls[controls.length - 1]; + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } + }; + dialog.addEventListener("keydown", onKey); + return () => { + dialog.removeEventListener("keydown", onKey); + triggerRef.current?.focus(); + }; + }, [onClose, triggerRef]); + + const toggle = (id: string) => + setPicked((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + + const memberIds = nextMemberIds( + group.memberIds, + picked, + bots.map((b) => b.id), + ); + const changed = memberIds.length !== group.memberIds.length || memberIds.some((id, i) => id !== group.memberIds[i]); + + const save = () => { + if (!memberIds.length) return; + const opened = openedMemberIds.current; + const rosterChanged = + opened.length !== group.memberIds.length || opened.some((id, index) => id !== group.memberIds[index]); + if (rosterChanged) { + setSaveError("This room's members changed while the panel was open. Close it and try again."); + return; + } + if (changed) { + dispatch({ type: "patchGroup", groupId: group.id, patch: { memberIds } }); + track("room_members_changed", { + members: memberIds.length, + added: memberIds.filter((id) => !group.memberIds.includes(id)).length, + removed: group.memberIds.filter((id) => !memberIds.includes(id)).length, + }); + } + onClose(); + }; + + return ( +
e.target === e.currentTarget && onClose()} + > +
+
Manage Members
+
{group.name}
+ + {!memberIds.length &&
A room needs at least one bot.
} + {saveError && ( +
+ {saveError} +
+ )} +
+ + +
+
+
+ ); +} diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 7a0362cde..ecd60ed81 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -41,6 +41,7 @@ import { useDesktopCapabilities } from "./DesktopCapabilities"; import { MIN_QUERY, SearchResults } from "./SearchResults"; import { TeamLibraryPanel, type TeamImportResult } from "./TeamLibraryPanel"; import { RenameTitle } from "./RenameTitle"; +import { BotPickerList } from "./BotPickerList"; import { loadSidebarDensity, saveSidebarDensity, @@ -401,29 +402,12 @@ function NewRoomPanel({ onClose }: { onClose: () => void }) { placeholder="Room name (optional)" className="mb-3 w-full rounded-lg bg-raised/70 px-3 py-2 text-[14px] text-ink placeholder:text-ink-secondary focus:outline-none" /> -
- {bots.length === 0 && ( -
Create a bot first — rooms are made of bots.
- )} - {bots.map((b) => ( - - ))} -
+