diff --git a/server/index.test.ts b/server/index.test.ts index a4c21925d2..2d0544aa9b 100644 --- a/server/index.test.ts +++ b/server/index.test.ts @@ -558,6 +558,21 @@ describe("harness HTTP API", () => { const clearedEmpty = await api("PATCH", `/api/bots/${bot.id}`, { section: " " }); expect(clearedEmpty.status).toBe(200); expect(clearedEmpty.body.bot).not.toHaveProperty("section"); + + // rooms file under the same sidebar sections, with the same contract + const sectionRoom = (await api("POST", "/api/groups", { name: "Filed", memberIds: [bot.id] })).body.group; + const roomSectioned = await api("PATCH", `/api/groups/${sectionRoom.id}`, { section: " Clients " }); + expect(roomSectioned.status).toBe(200); + expect(roomSectioned.body.group).toMatchObject({ section: "Clients" }); + expect((await api("PATCH", `/api/groups/${sectionRoom.id}`, { section: 7 })).status).toBe(400); + expect((await api("PATCH", `/api/groups/${sectionRoom.id}`, { section: "S".repeat(61) })).status).toBe(400); + const roomSectionCleared = await api("PATCH", `/api/groups/${sectionRoom.id}`, { section: null }); + expect(roomSectionCleared.status).toBe(200); + expect(roomSectionCleared.body.group).not.toHaveProperty("section"); + const roomSectionEmpty = await api("PATCH", `/api/groups/${sectionRoom.id}`, { section: " " }); + expect(roomSectionEmpty.status).toBe(200); + expect(roomSectionEmpty.body.group).not.toHaveProperty("section"); + expect((await api("DELETE", `/api/groups/${sectionRoom.id}`)).status).toBe(200); expect(gated.body.bot.composio).toBe(false); expect((await api("PATCH", `/api/bots/${bot.id}`, { composio: true })).body.bot.composio).toBe(true); diff --git a/server/index.ts b/server/index.ts index 1064f9427c..80307b615b 100644 --- a/server/index.ts +++ b/server/index.ts @@ -3161,6 +3161,17 @@ const server = createServer(async (req, res) => { patch.pinnedMessageId = body.pinnedMessageId; } else return json(res, 400, { error: "pinnedMessageId must be a message id" }); } + // same contract as a bot's sidebar section: null/"" clears, 60 chars max + if (body.section !== undefined) { + if (body.section === null) patch.section = undefined; + else if (typeof body.section !== "string") return json(res, 400, { error: "section must be a string" }); + else { + const trimmed = body.section.trim(); + if (!trimmed) patch.section = undefined; + else if (trimmed.length > 60) return json(res, 400, { error: "section must be at most 60 characters" }); + else patch.section = trimmed; + } + } const group = store.patchGroup(m[1], patch); if (!group) return json(res, 404, { error: "no such room" }); return json(res, 200, { group }); diff --git a/server/store.ts b/server/store.ts index 449694b246..292c13a01a 100644 --- a/server/store.ts +++ b/server/store.ts @@ -139,6 +139,9 @@ export interface GroupRecord { /** the one message pinned to the top of this room's transcript. A pin id * that no longer resolves (edited away, deleted) simply renders nothing. */ pinnedMessageId?: string; + /** sidebar section heading this room is filed under; shares the bots' + * namespace so one heading can hold a project's room and its people */ + section?: string; } /** One task = one conversation with its own context. @@ -585,7 +588,7 @@ export class Store { ); } - patchGroup(id: string, patch: Partial>): GroupRecord | null { + patchGroup(id: string, patch: Partial>): GroupRecord | null { const group = this.group(id); if (!group) return null; Object.assign(group, patch); diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index ecd60ed81a..4c7f86da03 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -221,6 +221,15 @@ function GroupListItem({ e.preventDefault(); onMenu({ groupId: group.id, x: e.clientX, y: e.clientY }); }} + // the menu must be reachable without a pointer: Shift+F10, and the + // dedicated ContextMenu key (whose native event carries no useful + // coordinates) both open it centered on the row + onKeyDown={(e) => { + if (e.key !== "ContextMenu" && !(e.shiftKey && e.key === "F10")) return; + e.preventDefault(); + const rect = e.currentTarget.getBoundingClientRect(); + onMenu({ groupId: group.id, x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }); + }} className={cn( "relative flex w-full items-center rounded-xl text-left", density === "icons" ? "justify-center px-1 py-1.5" : density === "compact" ? "gap-2 px-2 py-1.5" : "gap-3 px-3 py-2.5", @@ -250,9 +259,11 @@ function GroupListItem({ function RoomContextMenu({ menu, onClose, + onMoveToSection, }: { menu: { groupId: string; x: number; y: number }; onClose: () => void; + onMoveToSection: (groupId: string) => void; }) { const { state, dispatch } = useStore(); const group = state.groups.find((g) => g.id === menu.groupId); @@ -280,7 +291,7 @@ function RoomContextMenu({ if (name) dispatch({ type: "patchGroup", groupId: group.id, patch: { name } }); onClose(); }; - const top = Math.min(menu.y, window.innerHeight - 164); + const top = Math.min(menu.y, window.innerHeight - 204); const left = Math.min(menu.x, window.innerWidth - 240); return createPortal(
)} + ))}
@@ -531,14 +561,11 @@ function SectionPicker({ Add - {bot.section && ( + {current && ( <>
)} - {visibleGroups.map((g) => ( + {unsectionedGroups.map((g) => ( ))} {visibleBots.map((b) => ( @@ -1378,6 +1411,11 @@ export function Sidebar({ open, onClose }: { open: boolean; onClose: () => void {sectionNames.map((name) => ( {density !== "icons" && } + {sectionedGroups + .filter((g) => g.section === name) + .map((g) => ( + + ))} {sectionedBots .filter((b) => b.section === name) .map((b) => ( @@ -1455,13 +1493,29 @@ export function Sidebar({ open, onClose }: { open: boolean; onClose: () => void /> )} {sectionPicker && ( - setSectionPicker(null)} /> + b.id === sectionPicker.botId)?.section} + anchor={sectionPicker} + onClose={() => setSectionPicker(null)} + onAssign={(section) => dispatch({ type: "updateBot", botId: sectionPicker.botId, patch: { section } })} + /> )} {roomMenu && ( setRoomMenu(null)} + onMoveToSection={(groupId) => setRoomSectionPicker({ groupId, x: roomMenu.x, y: roomMenu.y })} + /> + )} + {roomSectionPicker && ( + g.id === roomSectionPicker.groupId)?.section} + anchor={roomSectionPicker} + onClose={() => setRoomSectionPicker(null)} + onAssign={(section) => + dispatch({ type: "patchGroup", groupId: roomSectionPicker.groupId, patch: { section } }) + } /> )} {newRoom && setNewRoom(false)} />} diff --git a/src/state/store.tsx b/src/state/store.tsx index a7f46d0679..3a4d535b83 100644 --- a/src/state/store.tsx +++ b/src/state/store.tsx @@ -111,6 +111,8 @@ export interface Group { pinnedCwd?: string | null; /** the one message pinned to the top of this room's transcript */ pinnedMessageId?: string; + /** sidebar section heading this room is filed under (shared with bots) */ + section?: string; messages: Message[]; } @@ -386,7 +388,7 @@ export type Action = | { type: "patchGroup"; groupId: string; - patch: Partial>; + patch: Partial>; } | { type: "deleteGroup"; groupId: string } | { type: "toggleReaction"; threadId: string; messageId: string; emoji: string }