Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions server/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
11 changes: 9 additions & 2 deletions server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
46 changes: 46 additions & 0 deletions src/components/BotPickerList.tsx
Original file line number Diff line number Diff line change
@@ -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<string>;
onToggle: (id: string) => void;
/** shown in place of the list when there is nothing to pick from */
emptyHint: string;
}) {
return (
<div className="flex max-h-64 flex-col gap-0.5 overflow-y-auto">
{bots.length === 0 && <div className="px-2 py-4 text-center text-[13px] text-ink-secondary">{emptyHint}</div>}
{bots.map((b) => (
<button
key={b.id}
onClick={() => onToggle(b.id)}
role="checkbox"
aria-checked={picked.has(b.id)}
className="flex items-center gap-2.5 rounded-lg px-2 py-1.5 text-left hover:bg-raised/50"
>
<BotAvatar bot={b} state="happy" size={28} />
<span className="min-w-0 flex-1 truncate text-[14px] text-ink">{b.name}</span>
<span
className={cn(
"flex size-[18px] shrink-0 items-center justify-center rounded-full border",
picked.has(b.id) ? "border-accent bg-accent text-white" : "border-hairline/60",
)}
>
{picked.has(b.id) && <Check size={12} />}
</span>
</button>
))}
</div>
);
}
64 changes: 44 additions & 20 deletions src/components/GroupView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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";
Expand Down Expand Up @@ -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<HTMLButtonElement>(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)),
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -464,13 +469,33 @@ export function GroupView({ group }: { group: Group }) {
}
};

// Static mauses: one per member, a ring + dot on whoever is working.
const memberMauses = members.map((b) => (
<span
key={b.id}
title={`${b.name}${group.busyBotId === b.id ? " — working…" : ""}`}
className={cn(
"relative inline-flex rounded-full",
group.busyBotId === b.id && "ring-2 ring-accent/50 ring-offset-1 ring-offset-app",
)}
>
<MausAvatar color={b.color} state={normalizeState(b.mascotExpression) ?? "happy"} size={24} animated={false} />
{group.busyBotId === b.id && (
<span className="absolute -right-0.5 -top-0.5 size-2 rounded-full border border-app bg-accent" />
)}
</span>
));

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;

return (
<main className="relative flex h-full min-w-0 flex-1 flex-col bg-app">
<GroupCallOverlay group={group} members={members} />
{membersOpen && !group.dm && (
<ManageMembersPanel group={group} onClose={closeMembers} triggerRef={membersTriggerRef} />
)}
{/* Header: static member mauses; a ring + dot marks the working bot. */}
<div
className={cn(
Expand All @@ -486,26 +511,25 @@ export function GroupView({ group }: { group: Group }) {
<GroupCallButton group={group} members={members} />
{!group.dm && <RoomWorkingFolderChip group={group} onToggle={() => setFolderOpen((open) => !open)} />}
{!group.dm && <DefaultResponderSelect group={group} members={members} />}
{members.map((b) => (
<span
key={b.id}
title={`${b.name}${group.busyBotId === b.id ? " — working…" : ""}`}
className={cn(
"relative inline-flex rounded-full",
group.busyBotId === b.id && "ring-2 ring-accent/50 ring-offset-1 ring-offset-app",
)}
{group.dm ? (
memberMauses
) : (
// The roster lives where you already look to see who is in the
// room; a dashed + says the row is editable without shouting.
<button
ref={membersTriggerRef}
type="button"
onClick={() => 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"
>
<MausAvatar
color={b.color}
state={normalizeState(b.mascotExpression) ?? "happy"}
size={24}
animated={false}
/>
{group.busyBotId === b.id && (
<span className="absolute -right-0.5 -top-0.5 size-2 rounded-full border border-app bg-accent" />
)}
</span>
))}
{memberMauses}
<span className="flex size-[18px] items-center justify-center rounded-full border border-dashed border-hairline/70 text-ink-secondary">
<Plus size={11} />
</span>
</button>
)}
</div>
</div>

Expand Down
Loading
Loading