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
15 changes: 15 additions & 0 deletions server/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
11 changes: 11 additions & 0 deletions server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
5 changes: 4 additions & 1 deletion server/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -585,7 +588,7 @@ export class Store {
);
}

patchGroup(id: string, patch: Partial<Pick<GroupRecord, "name" | "memberIds" | "defaultResponder" | "bulletin" | "unread" | "busyBotId" | "cwd">>): GroupRecord | null {
patchGroup(id: string, patch: Partial<Pick<GroupRecord, "name" | "memberIds" | "defaultResponder" | "bulletin" | "unread" | "busyBotId" | "cwd" | "section">>): GroupRecord | null {
const group = this.group(id);
if (!group) return null;
Object.assign(group, patch);
Expand Down
98 changes: 76 additions & 22 deletions src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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(
<div
Expand Down Expand Up @@ -340,6 +351,16 @@ function RoomContextMenu({
Rename Room
</button>
)}
<button
onClick={() => {
onClose();
onMoveToSection(group.id);
}}
className="flex w-full items-center gap-3 px-3.5 py-2 text-left text-[14px] text-ink hover:bg-raised/70"
>
<FolderPlus size={16} className="text-ink-secondary" />
Move to section
</button>
<button
onClick={() => {
void navigator.clipboard?.writeText(group.threadId);
Expand Down Expand Up @@ -434,20 +455,24 @@ function SectionDivider({ name }: { name: string }) {
}

/** Move-to-section popover: existing sections as chips (checkmark on the
* bot's current one), a create field, and a remove action. Mirrors the
* target's current one), a create field, and a remove action. Serves bots
* and rooms alike — the caller supplies the assignment. Mirrors the
* context menu's fixed positioning + dismiss-on-outside-click contract. */
function SectionPicker({
botId,
current,
anchor,
onClose,
onAssign,
}: {
botId: string;
anchor: MenuState;
/** the target's current section; undefined = none */
current: string | undefined;
anchor: { x: number; y: number };
onClose: () => void;
/** "" clears — the server drops an empty section */
onAssign: (section: string) => void;
}) {
const { state, dispatch } = useStore();
const { state } = useStore();
const [name, setName] = useState("");
const bot = state.bots.find((b) => b.id === botId);
const trimmed = name.trim();

useEffect(() => {
Expand All @@ -465,12 +490,17 @@ function SectionPicker({
};
}, [onClose]);

if (!bot) return null;
// hidden bots can carry a stale assignment; don't offer it as a section
const sections = [...new Set(state.bots.filter((b) => !b.hidden && b.section).map((b) => b.section!))];
// hidden bots can carry a stale assignment; don't offer it as a section.
// Rooms and bots share one namespace, so a heading can hold both.
const sections = [
...new Set([
...state.bots.filter((b) => !b.hidden && b.section).map((b) => b.section!),
...state.groups.filter((g) => g.section).map((g) => g.section!),
]),
];

const assign = (section: string) => {
dispatch({ type: "updateBot", botId, patch: { section } });
onAssign(section);
onClose();
};

Expand All @@ -494,11 +524,11 @@ function SectionPicker({
onClick={() => assign(section)}
className={cn(
"flex w-full items-center justify-between gap-2 rounded-lg px-2.5 py-1.5 text-left text-[13px]",
section === bot.section ? "bg-raised text-ink" : "text-ink hover:bg-raised/70",
section === current ? "bg-raised text-ink" : "text-ink hover:bg-raised/70",
)}
>
<span className="truncate">{section}</span>
{section === bot.section && <Check size={14} className="shrink-0 text-accent" />}
{section === current && <Check size={14} className="shrink-0 text-accent" />}
</button>
))}
</div>
Expand Down Expand Up @@ -531,14 +561,11 @@ function SectionPicker({
Add
</button>
</form>
{bot.section && (
{current && (
<>
<div className="mx-2 my-1 border-t border-hairline/40" />
<button
onClick={() => {
dispatch({ type: "updateBot", botId, patch: { section: "" } });
onClose();
}}
onClick={() => assign("")}
className="flex w-full items-center gap-3 px-3.5 py-2 text-left text-[13px] text-danger hover:bg-raised/70"
>
<FolderMinus size={15} />
Expand Down Expand Up @@ -960,6 +987,7 @@ export function Sidebar({ open, onClose }: { open: boolean; onClose: () => void
const [menu, setMenu] = useState<MenuState | null>(null);
const [sectionPicker, setSectionPicker] = useState<MenuState | null>(null);
const [roomMenu, setRoomMenu] = useState<{ groupId: string; x: number; y: number } | null>(null);
const [roomSectionPicker, setRoomSectionPicker] = useState<{ groupId: string; x: number; y: number } | null>(null);
const [plusOpen, setPlusOpen] = useState(false);
const [newRoom, setNewRoom] = useState(false);
const [teamLibraryOpen, setTeamLibraryOpen] = useState(false);
Expand Down Expand Up @@ -1156,13 +1184,18 @@ export function Sidebar({ open, onClose }: { open: boolean; onClose: () => void
const visibleBots = matchingBots
.filter((bot) => !bot.chiefOfStaff && !bot.section)
.sort((a, b) => Number(b.pinned ?? false) - Number(a.pinned ?? false));
const visibleGroups = state.groups.filter((g) => !q || g.name.toLowerCase().includes(q));
const sectionedGroups = visibleGroups.filter((g) => g.section);
const unsectionedGroups = visibleGroups.filter((g) => !g.section);
// sections keep first-appearance order within the current list; a section
// whose bots all moved away (or fell out of the filter) simply vanishes
// whose members all moved away (or fell out of the filter) simply vanishes
const sectionNames: string[] = [];
for (const bot of sectionedBots) {
if (!sectionNames.includes(bot.section!)) sectionNames.push(bot.section!);
}
const visibleGroups = state.groups.filter((g) => !q || g.name.toLowerCase().includes(q));
for (const group of sectionedGroups) {
if (!sectionNames.includes(group.section!)) sectionNames.push(group.section!);
}
const activeBotCount = state.bots.filter((bot) => !bot.hidden).length;
const archivedBots = state.bots.filter((bot) => bot.hidden);
const pendingTeamUndo = teamFeedback?.undo;
Expand Down Expand Up @@ -1362,7 +1395,7 @@ export function Sidebar({ open, onClose }: { open: boolean; onClose: () => void
/>
</div>
)}
{visibleGroups.map((g) => (
{unsectionedGroups.map((g) => (
<GroupListItem key={g.id} group={g} density={density} onMenu={setRoomMenu} />
))}
{visibleBots.map((b) => (
Expand All @@ -1378,6 +1411,11 @@ export function Sidebar({ open, onClose }: { open: boolean; onClose: () => void
{sectionNames.map((name) => (
<Fragment key={name}>
{density !== "icons" && <SectionDivider name={name} />}
{sectionedGroups
.filter((g) => g.section === name)
.map((g) => (
<GroupListItem key={g.id} group={g} density={density} onMenu={setRoomMenu} />
))}
{sectionedBots
.filter((b) => b.section === name)
.map((b) => (
Expand Down Expand Up @@ -1455,13 +1493,29 @@ export function Sidebar({ open, onClose }: { open: boolean; onClose: () => void
/>
)}
{sectionPicker && (
<SectionPicker botId={sectionPicker.botId} anchor={sectionPicker} onClose={() => setSectionPicker(null)} />
<SectionPicker
current={state.bots.find((b) => b.id === sectionPicker.botId)?.section}
anchor={sectionPicker}
onClose={() => setSectionPicker(null)}
onAssign={(section) => dispatch({ type: "updateBot", botId: sectionPicker.botId, patch: { section } })}
/>
)}
{roomMenu && (
<RoomContextMenu
key={roomMenu.groupId}
menu={roomMenu}
onClose={() => setRoomMenu(null)}
onMoveToSection={(groupId) => setRoomSectionPicker({ groupId, x: roomMenu.x, y: roomMenu.y })}
/>
)}
{roomSectionPicker && (
<SectionPicker
current={state.groups.find((g) => g.id === roomSectionPicker.groupId)?.section}
anchor={roomSectionPicker}
onClose={() => setRoomSectionPicker(null)}
onAssign={(section) =>
dispatch({ type: "patchGroup", groupId: roomSectionPicker.groupId, patch: { section } })
}
/>
)}
{newRoom && <NewRoomPanel onClose={() => setNewRoom(false)} />}
Expand Down
4 changes: 3 additions & 1 deletion src/state/store.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
}

Expand Down Expand Up @@ -386,7 +388,7 @@ export type Action =
| {
type: "patchGroup";
groupId: string;
patch: Partial<Pick<Group, "name" | "bulletin" | "memberIds" | "defaultResponder" | "pinnedMessageId">>;
patch: Partial<Pick<Group, "name" | "bulletin" | "memberIds" | "defaultResponder" | "pinnedMessageId" | "section">>;
}
| { type: "deleteGroup"; groupId: string }
| { type: "toggleReaction"; threadId: string; messageId: string; emoji: string }
Expand Down
Loading