diff --git a/docs/screenshots/team-switcher.gif b/docs/screenshots/team-switcher.gif new file mode 100644 index 000000000..d96059de9 Binary files /dev/null and b/docs/screenshots/team-switcher.gif differ diff --git a/server/index.test.ts b/server/index.test.ts index 3819f5b63..d7c9195bf 100644 --- a/server/index.test.ts +++ b/server/index.test.ts @@ -484,6 +484,119 @@ describe("harness HTTP API", () => { expect(after.body.bots.find((b: { id: string }) => b.id === bot.id)).toBeUndefined(); }); + it("creates, switches, and exports a named team", async () => { + const created = await api("POST", "/api/teams", { name: " Field Ops " }); + expect(created.status).toBe(201); + expect(created.body.team).toMatchObject({ name: "Field Ops" }); + expect(created.body.activeTeamId).toBe(created.body.team.id); + expect((await api("POST", "/api/teams", { name: "field ops" })).status).toBe(409); + expect((await api("POST", "/api/teams", { name: "" })).status).toBe(400); + expect((await api("POST", "/api/teams", { name: "T".repeat(61) })).status).toBe(400); + + const teamId = created.body.team.id as string; + const emptyExport = await api("POST", "/api/teams/export", { teamId }); + expect(emptyExport.status).toBe(400); + expect(emptyExport.body.error).toBe("Field Ops has no bots to export"); + const inTeam = (await api("POST", "/api/bots", { teamId })).body.bot; + expect(inTeam.teamId).toBe(teamId); + expect(inTeam.section).toBe("Field Ops"); + const outsider = (await api("POST", "/api/bots")).body.bot; + expect(outsider.teamId).toBeNull(); + expect((await api("POST", "/api/bots", { teamId: "missing" })).status).toBe(400); + + const room = ( + await api("POST", "/api/groups", { name: "Standup", memberIds: [inTeam.id], teamId }) + ).body.group; + expect(room.teamId).toBe(teamId); + + const exported = await api("POST", "/api/teams/export", { teamId }); + expect(exported.status).toBe(200); + expect(exported.body.team.name).toBe("Field Ops"); + expect(exported.body.team.members.map((member: { name: string }) => member.name)).toEqual([inTeam.name]); + + const renamed = await api("PATCH", `/api/teams/${teamId}`, { name: "Ops" }); + expect(renamed.status).toBe(200); + expect(renamed.body.team.name).toBe("Ops"); + expect((await api("GET", "/api/bots")).body.bots.find((bot: { id: string }) => bot.id === inTeam.id).section).toBe( + "Ops", + ); + + const mixed = await api("PATCH", `/api/bots/${inTeam.id}`, { teamId, section: "Stale Label" }); + expect(mixed.status).toBe(200); + expect(mixed.body.bot).toMatchObject({ teamId, section: "Ops" }); + const clearedTeam = await api("PATCH", `/api/bots/${outsider.id}`, { teamId: null, section: "Should Not Stick" }); + expect(clearedTeam.status).toBe(200); + expect(clearedTeam.body.bot.teamId).toBeNull(); + expect(clearedTeam.body.bot).not.toHaveProperty("section"); + + const all = await api("POST", "/api/teams/active", { id: null }); + expect(all.status).toBe(200); + expect(all.body.activeTeamId).toBeNull(); + expect((await api("GET", "/api/bots")).body.activeTeamId).toBeNull(); + + const removed = await api("DELETE", `/api/teams/${teamId}`); + expect(removed.status).toBe(200); + const state = (await api("GET", "/api/bots")).body; + expect(state.teams.find((team: { id: string }) => team.id === teamId)).toBeUndefined(); + expect(state.bots.find((bot: { id: string }) => bot.id === inTeam.id).teamId).toBeNull(); + expect(state.groups.find((group: { id: string }) => group.id === room.id).teamId).toBeNull(); + + await api("DELETE", `/api/bots/${inTeam.id}`); + await api("DELETE", `/api/bots/${outsider.id}`); + await api("DELETE", `/api/groups/${room.id}`); + }); + + it("import add and replace follow the requested team, not a lagging active team", async () => { + const home = (await api("POST", "/api/teams", { name: "Lag Home" })).body; + const other = (await api("POST", "/api/teams", { name: "Lag Other", activate: false })).body; + const homeBot = (await api("POST", "/api/bots", { name: "Lag Keeper", teamId: home.team.id })).body.bot; + const otherBot = (await api("POST", "/api/bots", { name: "Lag Guest", teamId: other.team.id })).body.bot; + expect((await api("POST", "/api/teams/active", { id: home.team.id })).status).toBe(200); + + const exportedHome = await api("POST", "/api/teams/export", { teamId: home.team.id }); + const added = await api("POST", `/api/teams/import?mode=add&teamId=${other.team.id}`, exportedHome.body); + expect(added.status).toBe(201); + expect(added.body.bots[0].teamId).toBe(other.team.id); + expect((await api("GET", "/api/bots")).body.bots.find((bot: { id: string }) => bot.id === homeBot.id).hidden).toBeFalsy(); + + const exported = await api("POST", "/api/teams/export", { teamId: other.team.id }); + const replaced = await api("POST", `/api/teams/import?mode=replace&teamId=${other.team.id}`, exported.body); + expect(replaced.status).toBe(201); + expect(replaced.body.archived.map((bot: { id: string }) => bot.id).sort()).toEqual( + [otherBot.id, added.body.bots[0].id].sort(), + ); + expect((await api("GET", "/api/bots")).body.bots.find((bot: { id: string }) => bot.id === homeBot.id).hidden).toBeFalsy(); + + for (const bot of [...added.body.bots, ...replaced.body.bots]) await api("DELETE", `/api/bots/${bot.id}`); + await api("DELETE", `/api/bots/${homeBot.id}`); + await api("DELETE", `/api/bots/${otherBot.id}`); + await api("DELETE", `/api/teams/${home.team.id}`); + await api("DELETE", `/api/teams/${other.team.id}`); + }); + + it("replace import archives only the active team", async () => { + const home = (await api("POST", "/api/teams", { name: "Home" })).body; + const visitor = (await api("POST", "/api/teams", { name: "Visitor", activate: false })).body; + const homeBot = (await api("POST", "/api/bots", { name: "Keeper", teamId: home.team.id })).body.bot; + const otherBot = (await api("POST", "/api/bots", { name: "Guest", teamId: visitor.team.id })).body.bot; + expect((await api("POST", "/api/teams/active", { id: home.team.id })).status).toBe(200); + + const exported = await api("POST", "/api/teams/export", { teamId: home.team.id }); + const replaced = await api("POST", "/api/teams/import?mode=replace", exported.body); + expect(replaced.status).toBe(201); + expect(replaced.body.archived.map((bot: { id: string }) => bot.id)).toEqual([homeBot.id]); + + const state = (await api("GET", "/api/bots")).body; + expect(state.bots.find((bot: { id: string }) => bot.id === homeBot.id).hidden).toBe(true); + expect(state.bots.find((bot: { id: string }) => bot.id === otherBot.id).hidden).toBeFalsy(); + + for (const bot of replaced.body.bots) await api("DELETE", `/api/bots/${bot.id}`); + await api("DELETE", `/api/bots/${homeBot.id}`); + await api("DELETE", `/api/bots/${otherBot.id}`); + await api("DELETE", `/api/teams/${home.team.id}`); + await api("DELETE", `/api/teams/${visitor.team.id}`); + }); + it("saves, serves, and guards image attachments", async () => { // a real 1x1 PNG so the bytes round-trip intact const png = Buffer.from( @@ -701,8 +814,14 @@ describe("harness HTTP API", () => { expect(invalid.status).toBe(400); expect((await api("POST", "/api/teams/import?mode=erase", exported.body)).status).toBe(400); - const beforeReplace = (await api("GET", "/api/bots")).body.bots.filter( - (bot: { hidden?: boolean }) => !bot.hidden, + const mid = (await api("GET", "/api/bots")).body; + const beforeReplace = mid.bots.filter( + (bot: { hidden?: boolean; teamId?: string | null }) => + !bot.hidden && bot.teamId === mid.activeTeamId, + ); + const outsiders = mid.bots.filter( + (bot: { hidden?: boolean; teamId?: string | null }) => + !bot.hidden && bot.teamId !== mid.activeTeamId, ); const replaced = await api("POST", "/api/teams/import?mode=replace", exported.body); expect(replaced.status).toBe(201); @@ -711,8 +830,9 @@ describe("harness HTTP API", () => { ); expect(replaced.body.archivedBots.every((bot: { hidden?: boolean }) => bot.hidden)).toBe(true); const afterReplace = (await api("GET", "/api/bots")).body.bots; - expect(afterReplace.filter((bot: { hidden?: boolean }) => !bot.hidden).map((bot: { id: string }) => bot.id).sort()).toEqual( - replaced.body.bots.map((bot: { id: string }) => bot.id).sort(), + const visibleAfter = afterReplace.filter((bot: { hidden?: boolean }) => !bot.hidden).map((bot: { id: string }) => bot.id); + expect(visibleAfter.sort()).toEqual( + [...outsiders.map((bot: { id: string }) => bot.id), ...replaced.body.bots.map((bot: { id: string }) => bot.id)].sort(), ); expect((await api("GET", "/api/bots")).body.groups).toHaveLength(roomsBefore); diff --git a/server/index.ts b/server/index.ts index 15f278122..2283d5f23 100644 --- a/server/index.ts +++ b/server/index.ts @@ -72,6 +72,7 @@ import { ProviderRegistry } from "./harness/registry.ts"; import { cancelPeerApprovalsFor, cancelPeerApprovalsForThread, dismissStalePeerCards, requestPeerApproval, resolvePeerComms, type ApprovalBus } from "./peer-approval.ts"; import { mentionedBots, + MAX_TEAM_NAME, roomResponders, Store, type GroupDefaultResponder, @@ -267,7 +268,12 @@ const wireTask = ({ resumeCursors, lastInstanceId, ...task }: TaskRecord) => tas const wireBot = (bot: NonNullable>) => { const { resumeCursors, tasks, ...rest } = bot; - return { ...rest, avatarUrl: rest.avatarUrl ?? null, ...(tasks ? { tasks: tasks.map(wireTask) } : {}) }; + return { + ...rest, + teamId: rest.teamId ?? null, + avatarUrl: rest.avatarUrl ?? null, + ...(tasks ? { tasks: tasks.map(wireTask) } : {}), + }; }; /** Profile URLs are app-owned references, not merely strings with a trusted @@ -311,12 +317,15 @@ store.onChange((change) => { break; case "group": { const group = store.group(change.groupId); - if (group) broadcast({ kind: "group", group }); + if (group) broadcast({ kind: "group", group: { ...group, teamId: group.teamId ?? null } }); break; } case "group.deleted": broadcast({ kind: "group.deleted", groupId: change.groupId }); break; + case "teams": + broadcast({ kind: "teams", teams: change.teams, activeTeamId: change.activeTeamId }); + break; } }); @@ -2741,7 +2750,9 @@ const server = createServer(async (req, res) => { if (limit === null) return json(res, 400, { error: "messages must be a non-negative whole number" }); return json(res, 200, { bots: store.bots.map((bot) => ({ ...publicBot(bot), ...messagePage(bot.threadId, limit) })), - groups: store.groups.map((g) => ({ ...g, ...messagePage(g.threadId, limit) })), + groups: store.groups.map((g) => ({ ...g, teamId: g.teamId ?? null, ...messagePage(g.threadId, limit) })), + teams: store.teams, + activeTeamId: store.activeTeamId, computerControl: Object.fromEntries( store.bots.map((bot) => { const snapshot = computerControl.snapshot(bot.id); @@ -2871,7 +2882,10 @@ const server = createServer(async (req, res) => { if (!ids) activePaths.set(threadId, (ids = new Set(store.activePath(threadId).map((m) => m.id)))); return ids.has(messageId); }; - const hits = searchMessages(q, limit) + const teamId = url.searchParams.get("teamId"); + if (teamId && !store.team(teamId)) return json(res, 404, { error: "no such team" }); + const pool = teamId ? Math.min(Math.max(limit * 10, 200), 1000) : limit; + const hits = searchMessages(q, pool) .map((hit) => { const bot = store.botByThread(hit.threadId); const group = bot ? undefined : store.groupByThread(hit.threadId); @@ -2884,7 +2898,25 @@ const server = createServer(async (req, res) => { if (group) return { ...hit, groupId: group.id, name: group.name, onActivePath: active }; return null; }) - .filter((hit): hit is NonNullable => hit !== null); + .filter((hit): hit is NonNullable => hit !== null) + .filter((hit) => { + if (!teamId) return true; + if ("botId" in hit) { + const owner = store.bot(hit.botId); + return Boolean(owner && !owner.hidden && owner.teamId === teamId); + } + if ("groupId" in hit) { + const room = store.group(hit.groupId); + if (!room) return false; + if (room.teamId) return room.teamId === teamId; + const members = room.memberIds + .map((id) => store.bot(id)) + .filter((member): member is NonNullable => member != null && !member.hidden); + return members.length > 0 && members.every((member) => member.teamId === teamId); + } + return false; + }) + .slice(0, limit); return json(res, 200, { hits }); } @@ -2941,19 +2973,71 @@ const server = createServer(async (req, res) => { typeof body.name === "string" && body.name.trim() ? body.name.trim() : `${store.bot(memberIds[0])!.name} & co.`; + const teamId = typeof body.teamId === "string" ? body.teamId : undefined; + if (teamId && !store.team(teamId)) return json(res, 400, { error: "no such team" }); const group = store.createGroup(name, memberIds); - return json(res, 201, { group: { ...group, messages: [] } }); + if (teamId) store.patchGroup(group.id, { teamId }); + const saved = store.group(group.id) ?? group; + return json(res, 201, { group: { ...saved, messages: [] } }); + } + if (method === "GET" && path === "/api/teams") { + return json(res, 200, { teams: store.teams, activeTeamId: store.activeTeamId }); + } + if (method === "POST" && path === "/api/teams") { + const body = await readBody(req); + if (typeof body.name !== "string") return json(res, 400, { error: "name must be a string" }); + const name = body.name.trim(); + if (!name) return json(res, 400, { error: "A team needs a name" }); + if (name.length > MAX_TEAM_NAME) { + return json(res, 400, { error: `name must be at most ${MAX_TEAM_NAME} characters` }); + } + const team = store.createTeam(name); + if (!team) return json(res, 409, { error: "A team with that name already exists" }); + if (body.activate !== false) store.setActiveTeam(team.id); + return json(res, 201, { team, teams: store.teams, activeTeamId: store.activeTeamId }); + } + if (method === "POST" && path === "/api/teams/active") { + const body = await readBody(req); + const id = body.id === null || body.id === "" ? null : body.id; + if (id !== null && typeof id !== "string") return json(res, 400, { error: "id must be a team id or null" }); + if (!store.setActiveTeam(id)) return json(res, 404, { error: "no such team" }); + return json(res, 200, { teams: store.teams, activeTeamId: store.activeTeamId }); + } + m = path.match(/^\/api\/teams\/([\w-]+)$/); + if (m && method === "PATCH") { + const body = await readBody(req); + if (!store.team(m[1])) return json(res, 404, { error: "no such team" }); + if (typeof body.name !== "string") return json(res, 400, { error: "name must be a string" }); + const name = body.name.trim(); + if (!name) return json(res, 400, { error: "A team needs a name" }); + if (name.length > MAX_TEAM_NAME) { + return json(res, 400, { error: `name must be at most ${MAX_TEAM_NAME} characters` }); + } + const team = store.renameTeam(m[1], name); + if (!team) return json(res, 409, { error: "A team with that name already exists" }); + return json(res, 200, { team, teams: store.teams, activeTeamId: store.activeTeamId }); + } + if (m && method === "DELETE") { + if (!store.deleteTeam(m[1])) return json(res, 404, { error: "no such team" }); + return json(res, 200, { ok: true, teams: store.teams, activeTeamId: store.activeTeamId }); } if (method === "POST" && path === "/api/teams/export") { const body = await readBody(req); const profileName = cfg.profile?.name?.trim(); - const name = + let memberIds = store.bots.filter((bot) => !bot.hidden).map((bot) => bot.id); + let name = typeof body.name === "string" && body.name.trim() ? body.name.trim() : profileName ? `${profileName}'s Team` : "My OpenMaus Team"; - const memberIds = store.bots.filter((bot) => !bot.hidden).map((bot) => bot.id); + if (typeof body.teamId === "string") { + const team = store.team(body.teamId); + if (!team) return json(res, 404, { error: "no such team" }); + memberIds = store.bots.filter((bot) => !bot.hidden && bot.teamId === team.id).map((bot) => bot.id); + if (typeof body.name !== "string" || !body.name.trim()) name = team.name; + if (memberIds.length === 0) return json(res, 400, { error: `${team.name} has no bots to export` }); + } if (memberIds.length === 0) return json(res, 400, { error: "Create a bot before exporting your team" }); try { return json( @@ -3037,15 +3121,30 @@ const server = createServer(async (req, res) => { return json(res, 400, { error: error instanceof Error ? error.message : "Invalid team file" }); } + // The client may still have a team switch in flight. Scope add/replace + // to the team it asked for, not whatever /api/teams/active last wrote. + const requestedScope = url.searchParams.has("teamId") ? url.searchParams.get("teamId") : undefined; + let scopeTeamId: string | null; + if (requestedScope === undefined) scopeTeamId = store.activeTeamId; + else if (!requestedScope) scopeTeamId = null; + else if (!store.team(requestedScope)) return json(res, 400, { error: "no such team" }); + else scopeTeamId = requestedScope; + // Snapshot before creating anything so replace never archives the new // team. Old bots are hidden only after every new bot was created; a // failed import therefore leaves the current workspace untouched. + // A named team replaces that roster only; All bots still replaces + // every visible bot. + const previousActiveTeamId = store.activeTeamId; const archived = importMode === "replace" ? store.bots - .filter((bot) => !bot.hidden) + .filter( + (bot) => !bot.hidden && (scopeTeamId === null || bot.teamId === scopeTeamId), + ) .map((bot) => ({ id: bot.id, chiefOfStaff: Boolean(bot.chiefOfStaff) })) : []; const importedBots: ReturnType[] = []; + let createdTeamId: string | null = null; // Names already in use, hidden bots included: an archived bot can be // un-archived later, and a revived duplicate would be just as // ambiguous then. In replace mode this means re-importing your own @@ -3075,7 +3174,17 @@ const server = createServer(async (req, res) => { const bot = store.patchBot(id, { hidden: true, chiefOfStaff: false }); return bot ? [publicBot(bot)] : []; }); - const publicBots = importedBots.map(publicBot); + // Add into the team you're looking at; replace (or All bots) stands + // up a team named after the file and switches to it. + const existingTeam = + importMode === "add" && scopeTeamId ? store.team(scopeTeamId) : undefined; + const hostTeam = existingTeam ?? store.createTeamNamed(manifest.team.name); + if (!existingTeam) createdTeamId = hostTeam.id; + if (hostTeam.id !== store.activeTeamId) store.setActiveTeam(hostTeam.id); + for (const created of importedBots) { + store.patchBot(created.id, { teamId: hostTeam.id, section: hostTeam.name }); + } + const publicBots = importedBots.map((bot) => publicBot(store.bot(bot.id) ?? bot)); for (const bot of archivedBots) broadcast({ kind: "bot", bot }); for (const bot of publicBots) broadcast({ kind: "bot", bot }); @@ -3091,14 +3200,27 @@ const server = createServer(async (req, res) => { // before anyone has worked, which is the store's call, not ours. group = store.patchGroup(group.id, { cwd: projectCwd }) ?? group; } - broadcast({ kind: "group", group }); + group = store.patchGroup(group.id, { teamId: hostTeam.id }) ?? group; + broadcast({ kind: "group", group: { ...group, teamId: group.teamId ?? null } }); } - return json(res, 201, { bots: publicBots, archivedBots, archived, group }); + return json(res, 201, { + bots: publicBots, + archivedBots, + archived, + group, + team: hostTeam, + teams: store.teams, + activeTeamId: store.activeTeamId, + }); } catch (error) { // A room of deleted members must not survive either — patchGroup can - // throw (disk) after createGroup already saved. + // throw (disk) after createGroup already saved. Restore archived + // bots first so a failed replace does not leave the previous team hidden. + store.restoreArchivedBots(archived); if (group) store.deleteGroup(group.id); for (const bot of importedBots) store.deleteBot(bot.id); + if (createdTeamId) store.deleteTeam(createdTeamId); + store.setActiveTeam(store.team(previousActiveTeamId ?? "") ? previousActiveTeamId : null); throw error; } } @@ -3152,6 +3274,12 @@ const server = createServer(async (req, res) => { patch.pinnedMessageId = body.pinnedMessageId; } else return json(res, 400, { error: "pinnedMessageId must be a message id" }); } + if (body.teamId !== undefined) { + if (body.teamId === null || body.teamId === "") patch.teamId = undefined; + else if (typeof body.teamId !== "string") return json(res, 400, { error: "teamId must be a string" }); + else if (!store.team(body.teamId)) return json(res, 400, { error: "no such team" }); + else patch.teamId = body.teamId; + } const group = store.patchGroup(m[1], patch); if (!group) return json(res, 404, { error: "no such room" }); return json(res, 200, { group }); @@ -3206,7 +3334,10 @@ const server = createServer(async (req, res) => { return json(res, 200, { message: patched }); } if (method === "POST" && path === "/api/bots") { - const bot = store.createBot(); + const body = await readBody(req); + const teamId = typeof body.teamId === "string" ? body.teamId : undefined; + if (teamId && !store.team(teamId)) return json(res, 400, { error: "no such team" }); + const bot = store.createBot(teamId ? { teamId } : {}); store.patchBot(bot.id, { modelSelection: await defaultSelection() }); return json(res, 201, { bot: { @@ -3344,7 +3475,9 @@ const server = createServer(async (req, res) => { else { const trimmed = body.section.trim(); if (!trimmed) section = null; - else if (trimmed.length > 60) return json(res, 400, { error: "section must be at most 60 characters" }); + else if (trimmed.length > MAX_TEAM_NAME) { + return json(res, 400, { error: `section must be at most ${MAX_TEAM_NAME} characters` }); + } else section = trimmed; } } @@ -3361,6 +3494,26 @@ const server = createServer(async (req, res) => { } else return json(res, 400, { error: "pinnedMessageId must be a message id" }); } if (section !== undefined) patch.section = section ?? undefined; + if (body.teamId !== undefined) { + if (body.teamId === null || body.teamId === "") { + patch.teamId = undefined; + patch.section = undefined; + } else if (typeof body.teamId !== "string") { + return json(res, 400, { error: "teamId must be a string" }); + } else { + const team = store.team(body.teamId); + if (!team) return json(res, 400, { error: "no such team" }); + patch.teamId = team.id; + patch.section = team.name; + } + } else if (section !== undefined) { + if (section === null) patch.teamId = undefined; + else { + const team = store.findOrCreateTeam(section); + if (!team) return json(res, 400, { error: `section must be at most ${MAX_TEAM_NAME} characters` }); + patch.teamId = team.id; + } + } // per-bot gate on the workspace's connected apps (Composio) if (body.composio !== undefined) { if (typeof body.composio !== "boolean") return json(res, 400, { error: "composio must be true or false" }); diff --git a/server/store.test.ts b/server/store.test.ts index 2baf0c89c..c2edbf52e 100644 --- a/server/store.test.ts +++ b/server/store.test.ts @@ -666,3 +666,142 @@ describe("Store task working folder — cloud runs", () => { expect(store.pinTaskCwd(bot.id, bot.threadId)).toBeNull(); }); }); + +describe("Store teams", () => { + beforeEach(() => { + rmSync(DATA_DIR, { recursive: true, force: true }); + }); + + it("creates, renames, and restores a team across restart", () => { + const store = new Store(selection); + const team = store.createTeam("Engineering"); + expect(team).toMatchObject({ name: "Engineering" }); + expect(store.createTeam("engineering")).toBeNull(); + expect(store.createTeam("")).toBeNull(); + expect(store.createTeam("E".repeat(61))).toBeNull(); + + const bot = store.createBot({ name: "Scout", teamId: team!.id }); + expect(bot.teamId).toBe(team!.id); + expect(bot.section).toBe("Engineering"); + + expect(store.renameTeam(team!.id, "Platform")).toMatchObject({ name: "Platform" }); + expect(store.bot(bot.id)?.section).toBe("Platform"); + expect(store.setActiveTeam(team!.id)).toBe(true); + expect(store.renameTeam(team!.id, "Platform")).toMatchObject({ name: "Platform" }); + expect(store.renameTeam(team!.id, "")).toBeNull(); + + const reloaded = new Store(selection); + expect(reloaded.teams).toEqual([expect.objectContaining({ id: team!.id, name: "Platform" })]); + expect(reloaded.activeTeamId).toBe(team!.id); + expect(reloaded.bot(bot.id)).toMatchObject({ teamId: team!.id, section: "Platform" }); + }); + + it("promotes leftover section labels into teams on load", () => { + const store = new Store(selection); + const scout = store.createBot({ name: "Scout" }); + const copy = store.createBot({ name: "Copy" }); + const spare = store.createBot({ name: "Spare" }); + store.patchBot(scout.id, { section: "Engineering" }); + store.patchBot(copy.id, { section: "Marketing" }); + const room = store.createGroup("Standup", [scout.id, spare.id]); + + const reloaded = new Store(selection); + const engineering = reloaded.teams.find((team) => team.name === "Engineering"); + const marketing = reloaded.teams.find((team) => team.name === "Marketing"); + expect(engineering).toBeTruthy(); + expect(marketing).toBeTruthy(); + expect(reloaded.bot(scout.id)?.teamId).toBe(engineering!.id); + expect(reloaded.bot(copy.id)?.teamId).toBe(marketing!.id); + expect(reloaded.bot(spare.id)?.teamId).toBeUndefined(); + // mixed-member room stays unassigned + expect(reloaded.group(room.id)?.teamId).toBeUndefined(); + }); + + it("assigns a room to a team when every member already belongs", () => { + const store = new Store(selection); + const team = store.createTeam("Engineering")!; + const scout = store.createBot({ name: "Scout", teamId: team.id }); + const reviewer = store.createBot({ name: "Reviewer", teamId: team.id }); + store.createGroup("Standup", [scout.id, reviewer.id]); + const groupsFile = join(DATA_DIR, "groups.json"); + const saved = JSON.parse(readFileSync(groupsFile, "utf8")); + delete saved[0].teamId; + writeFileSync(groupsFile, JSON.stringify(saved)); + + const reloaded = new Store(selection); + expect(reloaded.groups[0]?.teamId).toBe(team.id); + }); + + it("deleteTeam unassigns members and clears the active team", () => { + const store = new Store(selection); + const team = store.createTeam("Engineering")!; + const bot = store.createBot({ name: "Scout", teamId: team.id }); + const group = store.createGroup("Standup", [bot.id]); + store.patchGroup(group.id, { teamId: team.id }); + store.setActiveTeam(team.id); + + expect(store.deleteTeam(team.id)).toBe(true); + expect(store.teams).toEqual([]); + expect(store.activeTeamId).toBeNull(); + expect(store.bot(bot.id)?.teamId).toBeUndefined(); + expect(store.bot(bot.id)?.section).toBeUndefined(); + expect(store.group(group.id)?.teamId).toBeUndefined(); + expect(store.bot(bot.id)?.name).toBe("Scout"); + + const reloaded = new Store(selection); + expect(reloaded.teams).toEqual([]); + expect(reloaded.bot(bot.id)?.name).toBe("Scout"); + }); + + it("restoreArchivedBots unhides the previous team and puts the Chief flag back", () => { + const store = new Store(selection); + const chief = store.createBot({ name: "Chief" }); + store.setChiefOfStaff(chief.id); + const other = store.createBot({ name: "Other" }); + const archived = [chief, other].map((bot) => ({ + id: bot.id, + chiefOfStaff: Boolean(store.bot(bot.id)?.chiefOfStaff), + })); + store.patchBot(chief.id, { hidden: true, chiefOfStaff: false }); + store.patchBot(other.id, { hidden: true, chiefOfStaff: false }); + + store.restoreArchivedBots(archived); + + expect(store.bot(chief.id)).toMatchObject({ hidden: false, chiefOfStaff: true }); + expect(store.bot(other.id)).toMatchObject({ hidden: false, chiefOfStaff: false }); + }); + + it("createTeamNamed numbers colliding import names", () => { + const store = new Store(selection); + store.createTeam("Field Team"); + expect(store.createTeamNamed("Field Team").name).toBe("Field Team 2"); + }); + + it("rolling back a replace import restores the previous team and its bots", () => { + const store = new Store(selection); + const home = store.createTeam("Home")!; + store.setActiveTeam(home.id); + const scout = store.createBot({ name: "Scout", teamId: home.id }); + const previousActiveTeamId = store.activeTeamId; + const archived = store.bots + .filter((bot) => !bot.hidden) + .map((bot) => ({ id: bot.id, chiefOfStaff: Boolean(bot.chiefOfStaff) })); + + const imported = store.createBot({ name: "Visitor" }, { seedMessages: false }); + store.patchBot(scout.id, { hidden: true, chiefOfStaff: false }); + const created = store.createTeamNamed("Imported"); + store.setActiveTeam(created.id); + store.patchBot(imported.id, { teamId: created.id, section: created.name }); + + store.restoreArchivedBots(archived); + store.deleteBot(imported.id); + store.deleteTeam(created.id); + store.setActiveTeam(store.team(previousActiveTeamId ?? "") ? previousActiveTeamId : null); + + expect(store.activeTeamId).toBe(home.id); + expect(store.team(created.id)).toBeUndefined(); + expect(store.bot(imported.id)).toBeFalsy(); + expect(store.bot(scout.id)).toMatchObject({ hidden: false, teamId: home.id, name: "Scout" }); + expect(store.teams.map((team) => team.id)).toEqual([home.id]); + }); +}); diff --git a/server/store.ts b/server/store.ts index 449694b24..94a5c997d 100644 --- a/server/store.ts +++ b/server/store.ts @@ -139,6 +139,18 @@ 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 team this room belongs to; absent = unassigned (All bots). */ + teamId?: string; +} + +export const MAX_TEAM_NAME = 60; + +/** A named roster the sidebar switcher can focus. Membership lives on + * bots and rooms (`teamId`); this record is the name + stable id. */ +export interface TeamRecord { + id: string; + name: string; + createdAt: number; } /** One task = one conversation with its own context. @@ -226,7 +238,8 @@ export type StoreChange = | { type: "bot"; botId: string } | { type: "bot.deleted"; botId: string } | { type: "group"; groupId: string } - | { type: "group.deleted"; groupId: string }; + | { type: "group.deleted"; groupId: string } + | { type: "teams"; teams: TeamRecord[]; activeTeamId: string | null }; /** What a task is called before its first message names it. */ export const UNTITLED_TASK = "New task"; @@ -285,8 +298,11 @@ export interface BotRecord { rewound?: boolean; pinned?: boolean; hidden?: boolean; - /** Optional labeled divider used to organize this bot in the sidebar. */ + /** Optional labeled divider used to organize this bot in the sidebar. + * Kept in sync with the bot's team name so older clients still group. */ section?: string; + /** Sidebar team this bot belongs to; absent = unassigned (All bots). */ + teamId?: string; /** the one message pinned to the top of this bot's active thread; a pin * that no longer resolves (branch switched away, deleted) renders nothing */ pinnedMessageId?: string; @@ -315,6 +331,7 @@ export interface BotRecord { const BOTS_FILE = join(DATA_DIR, "bots.json"); const GROUPS_FILE = join(DATA_DIR, "groups.json"); +const TEAMS_FILE = join(DATA_DIR, "teams.json"); const messagesFile = (threadId: string) => join(DATA_DIR, `messages-${threadId}.json`); const COLORS: MausColor[] = [ @@ -416,6 +433,9 @@ interface ThreadState { export class Store { bots: BotRecord[] = []; groups: GroupRecord[] = []; + teams: TeamRecord[] = []; + /** null = the All bots workspace view. */ + activeTeamId: string | null = null; private threads = new Map(); private defaultSelection: () => ModelSelection; private listeners = new Set<(change: StoreChange) => void>(); @@ -433,6 +453,7 @@ export class Store { } catch { this.groups = []; } + this.loadTeams(); // busy never survives a restart — no turn does either. Rooms saved // before default responders existed adopt their first member as lead. let botsMigrated = false; @@ -497,8 +518,10 @@ export class Store { if (JSON.stringify(normalized) !== JSON.stringify(g.defaultResponder)) groupsMigrated = true; g.defaultResponder = normalized; } - if (botsMigrated) this.saveBots(); - if (groupsMigrated) this.saveGroups(); + const sectionMigration = this.migrateSectionsToTeams(); + if (botsMigrated || sectionMigration.bots) this.saveBots(); + if (groupsMigrated || sectionMigration.groups) this.saveGroups(); + if (sectionMigration.teams) this.saveTeams(); // bots saved before tasks existed have one endless thread; adopt it as // their first task so nothing is lost and nothing special-cases it for (const b of this.bots) { @@ -533,6 +556,205 @@ export class Store { writeFileAtomic(GROUPS_FILE, JSON.stringify(this.groups.map(({ busyBotId, ...g }) => g), null, 2)); } + private saveTeams() { + writeFileAtomic( + TEAMS_FILE, + JSON.stringify({ teams: this.teams, activeTeamId: this.activeTeamId }, null, 2), + ); + } + + private loadTeams() { + try { + const raw = JSON.parse(readFileSync(TEAMS_FILE, "utf8")) as { + teams?: unknown; + activeTeamId?: unknown; + }; + if (Array.isArray(raw.teams)) { + const seen = new Set(); + for (const item of raw.teams) { + if (!item || typeof item !== "object" || Array.isArray(item)) continue; + const rec = item as { id?: unknown; name?: unknown; createdAt?: unknown }; + if (typeof rec.id !== "string" || !rec.id || seen.has(rec.id)) continue; + if (typeof rec.name !== "string" || !rec.name.trim()) continue; + seen.add(rec.id); + this.teams.push({ + id: rec.id, + name: rec.name.trim().slice(0, MAX_TEAM_NAME), + createdAt: typeof rec.createdAt === "number" && Number.isFinite(rec.createdAt) ? rec.createdAt : Date.now(), + }); + } + } + if (typeof raw.activeTeamId === "string" && this.teams.some((team) => team.id === raw.activeTeamId)) { + this.activeTeamId = raw.activeTeamId; + } else { + this.activeTeamId = null; + } + } catch { + this.teams = []; + this.activeTeamId = null; + } + } + + /** Promote leftover `section` labels into first-class teams so the + * switcher lists what the old sidebar dividers already grouped. */ + private migrateSectionsToTeams(): { bots: boolean; groups: boolean; teams: boolean } { + let bots = false; + let groups = false; + let teams = false; + const byName = new Map(this.teams.map((team) => [team.name.toLowerCase(), team])); + for (const bot of this.bots) { + if (bot.teamId && this.team(bot.teamId)) continue; + const section = bot.section?.trim(); + if (!section) { + if (bot.teamId) { + delete bot.teamId; + bots = true; + } + continue; + } + let team = byName.get(section.toLowerCase()); + if (!team) { + team = { id: newId(), name: section, createdAt: Date.now() }; + this.teams.push(team); + byName.set(section.toLowerCase(), team); + teams = true; + } + if (bot.teamId !== team.id) { + bot.teamId = team.id; + bots = true; + } + } + for (const group of this.groups) { + if (group.teamId && this.team(group.teamId)) continue; + if (group.dm) { + if (group.teamId) { + delete group.teamId; + groups = true; + } + continue; + } + const members = group.memberIds.map((id) => this.bot(id)).filter((bot): bot is BotRecord => Boolean(bot)); + const sharedTeamId = members[0]?.teamId; + const allShare = + typeof sharedTeamId === "string" && + Boolean(this.team(sharedTeamId)) && + members.length > 0 && + members.every((bot) => bot.teamId === sharedTeamId); + if (allShare && sharedTeamId) { + group.teamId = sharedTeamId; + groups = true; + } else if (group.teamId) { + delete group.teamId; + groups = true; + } + } + if (this.activeTeamId && !this.team(this.activeTeamId)) { + this.activeTeamId = null; + teams = true; + } + return { bots, groups, teams }; + } + + private emitTeams() { + this.saveTeams(); + this.emit({ type: "teams", teams: this.teams, activeTeamId: this.activeTeamId }); + } + + team(id: string): TeamRecord | undefined { + return this.teams.find((team) => team.id === id); + } + + private uniqueTeamName(requested: string): string { + const base = requested.trim().slice(0, MAX_TEAM_NAME) || "Team"; + const taken = (name: string) => this.teams.some((team) => team.name.toLowerCase() === name.toLowerCase()); + if (!taken(base)) return base; + for (let n = 2; n < 1000; n++) { + const suffix = ` ${n}`; + const name = `${base.slice(0, MAX_TEAM_NAME - suffix.length).trimEnd()}${suffix}`; + if (!taken(name)) return name; + } + return `${base.slice(0, 48)}-${newId().slice(0, 8)}`; + } + + createTeam(name: string): TeamRecord | null { + const normalized = name.trim(); + if (!normalized || normalized.length > MAX_TEAM_NAME) return null; + if (this.teams.some((team) => team.name.toLowerCase() === normalized.toLowerCase())) return null; + const team: TeamRecord = { id: newId(), name: normalized, createdAt: Date.now() }; + this.teams.push(team); + this.emitTeams(); + return team; + } + + /** Import path: never fail on a colliding display name. */ + createTeamNamed(name: string): TeamRecord { + return this.createTeam(this.uniqueTeamName(name))!; + } + + findOrCreateTeam(name: string): TeamRecord | null { + const normalized = name.trim(); + if (!normalized || normalized.length > MAX_TEAM_NAME) return null; + const existing = this.teams.find((team) => team.name.toLowerCase() === normalized.toLowerCase()); + return existing ?? this.createTeam(normalized); + } + + renameTeam(id: string, name: string): TeamRecord | null { + const team = this.team(id); + if (!team) return null; + const normalized = name.trim(); + if (!normalized || normalized.length > MAX_TEAM_NAME) return null; + if (this.teams.some((other) => other.id !== id && other.name.toLowerCase() === normalized.toLowerCase())) { + return null; + } + if (team.name === normalized) return team; + team.name = normalized; + let botsChanged = false; + for (const bot of this.bots) { + if (bot.teamId !== id) continue; + bot.section = normalized; + botsChanged = true; + } + if (botsChanged) this.saveBots(); + this.emitTeams(); + for (const bot of this.bots) { + if (bot.teamId === id) this.emit({ type: "bot", botId: bot.id }); + } + return team; + } + + deleteTeam(id: string): boolean { + if (!this.team(id)) return false; + this.teams = this.teams.filter((team) => team.id !== id); + let botsChanged = false; + let groupsChanged = false; + for (const bot of this.bots) { + if (bot.teamId !== id) continue; + delete bot.teamId; + delete bot.section; + botsChanged = true; + this.emit({ type: "bot", botId: bot.id }); + } + for (const group of this.groups) { + if (group.teamId !== id) continue; + delete group.teamId; + groupsChanged = true; + this.emit({ type: "group", groupId: group.id }); + } + if (this.activeTeamId === id) this.activeTeamId = null; + if (botsChanged) this.saveBots(); + if (groupsChanged) this.saveGroups(); + this.emitTeams(); + return true; + } + + setActiveTeam(id: string | null): boolean { + if (id && !this.team(id)) return false; + if (this.activeTeamId === id) return true; + this.activeTeamId = id; + this.emitTeams(); + return true; + } + // ── groups ──────────────────────────────────────────────────────────── /** Subscribe to every write. Listeners run after the write and after * save; a throwing listener never breaks the write. */ @@ -585,7 +807,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); @@ -765,7 +987,7 @@ export class Store { createBot( profile: Partial< - Pick + Pick > = {}, opts: { /** false = no greeting/onboarding seed. Imported bots must not open @@ -787,6 +1009,9 @@ export class Store { modelSelection: profile.modelSelection ?? this.defaultSelection(), resumeCursors: {}, createdAt: Date.now(), + ...(profile.teamId && this.team(profile.teamId) + ? { teamId: profile.teamId, section: this.team(profile.teamId)!.name } + : {}), }; bot.tasks = [{ threadId: bot.threadId, title: UNTITLED_TASK, createdAt: bot.createdAt, resumeCursors: {} }]; this.bots.unshift(bot); @@ -832,6 +1057,13 @@ export class Store { return bot; } + /** Undo a replace-import archive: bots become visible with their prior Chief flag. */ + restoreArchivedBots(archived: Array<{ id: string; chiefOfStaff: boolean }>): void { + for (const item of archived) { + this.patchBot(item.id, { hidden: false, chiefOfStaff: item.chiefOfStaff }); + } + } + /** The one way runtime state changes. Sets `activity` and derives `busy` * from it, so a reader that only knows busy sees the same truth. */ setActivity(botId: string, activity: BotActivity): BotRecord | null { diff --git a/src/App.tsx b/src/App.tsx index 7b959a8aa..a613fa122 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useState } from "react"; import { Loader2, Menu } from "lucide-react"; import { StoreProvider, useStore } from "@/state/store"; +import { botInActiveTeam } from "@/lib/team-scope"; import { Onboarding } from "@/components/Onboarding"; import { emailGateDone, initAnalytics } from "@/lib/analytics"; import { Sidebar } from "@/components/Sidebar"; @@ -27,7 +28,7 @@ function Shell() { const [drawerOpen, setDrawerOpen] = useState(false); const menuButtonRef = useRef(null); const group = state.groups.find((g) => g.id === state.selectedId); - const bot = group ? undefined : (state.bots.find((b) => b.id === state.selectedId) ?? state.bots[0]); + const bot = group ? undefined : state.bots.find((b) => b.id === state.selectedId); // Nothing on this machine can run a bot. A missing cloud login does not // count — that CLI can still host a local model. Wait for the first @@ -44,7 +45,7 @@ function Shell() { const onKey = (e: KeyboardEvent) => { const mod = e.metaKey || e.ctrlKey; if (!mod) return; - const bots = state.bots.filter((b) => !b.hidden); + const bots = state.bots.filter((b) => botInActiveTeam(b, state.activeTeamId)); if (e.key === "n" && !e.shiftKey) { e.preventDefault(); dispatch({ type: "newBot" }); @@ -65,7 +66,7 @@ function Shell() { }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); - }, [state.bots, state.selectedId, dispatch]); + }, [state.bots, state.selectedId, state.activeTeamId, dispatch]); // Picking a conversation closes the drawer: on a phone the chat is what you // asked for, and leaving the list up would hide it. Watching activeView too @@ -118,7 +119,11 @@ function Shell() {
- {state.connected ? "No bots yet" : "Connecting to the bot server…"} + {state.connected + ? state.activeTeamId + ? "No bots in this team yet" + : "No bots yet" + : "Connecting to the bot server…"}
{!state.connected && (
diff --git a/src/components/SearchResults.tsx b/src/components/SearchResults.tsx index 63e910fd0..b482d6e94 100644 --- a/src/components/SearchResults.tsx +++ b/src/components/SearchResults.tsx @@ -5,6 +5,7 @@ import { useEffect, useState } from "react"; import { GitBranch, Wrench } from "lucide-react"; import { api, useStore, formatTime } from "@/state/store"; +import { searchHitInActiveTeam } from "@/lib/team-scope"; import { MausAvatar } from "./Avatar"; import { cn } from "@/lib/cn"; import type { SearchHit } from "@/lib/search-hit"; @@ -31,7 +32,8 @@ export function SearchResults({ query, onLanded }: { query: string; onLanded: () setError(null); let alive = true; const t = setTimeout(() => { - api(`/api/search?q=${encodeURIComponent(q)}&limit=40`) + const team = state.activeTeamId ? `&teamId=${encodeURIComponent(state.activeTeamId)}` : ""; + api(`/api/search?q=${encodeURIComponent(q)}&limit=40${team}`) .then((r: { hits: SearchHit[] }) => alive && (setHits(r.hits), setError(null))) .catch((e: unknown) => alive && setError(e instanceof Error ? e.message : String(e))); }, DEBOUNCE_MS); @@ -39,10 +41,14 @@ export function SearchResults({ query, onLanded }: { query: string; onLanded: () alive = false; clearTimeout(t); }; - }, [q]); + }, [q, state.activeTeamId]); if (q.length < MIN_QUERY) return null; + const scopedHits = hits?.filter((hit) => + searchHitInActiveTeam(hit, state.bots, state.groups, state.activeTeamId), + ); + const land = async (hit: SearchHit) => { try { await landOnSearchHit(hit, state, dispatch); @@ -55,11 +61,11 @@ export function SearchResults({ query, onLanded }: { query: string; onLanded: () return (
- Messages{hits ? ` · ${hits.length}${hits.length === 40 ? "+" : ""}` : ""} + Messages{scopedHits ? ` · ${scopedHits.length}${scopedHits.length === 40 ? "+" : ""}` : ""}
{error &&
couldn't search: {error}
} - {hits && hits.length === 0 && !error &&
No messages match “{q}”
} - {hits?.map((hit) => { + {scopedHits && scopedHits.length === 0 && !error &&
No messages match “{q}”
} + {scopedHits?.map((hit) => { const bot = hit.botId ? state.bots.find((b) => b.id === hit.botId) : undefined; const before = hit.snippet.slice(0, hit.matchStart); const match = hit.snippet.slice(hit.matchStart, hit.matchStart + hit.matchLength); diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 7a0362cde..207c141f1 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -1,5 +1,5 @@ import { track } from "@/lib/analytics"; -import { Fragment, useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { Archive, @@ -8,6 +8,7 @@ import { Bot as BotIcon, CalendarDays, Check, + ChevronsUpDown, ClipboardCopy, Copy, Crown, @@ -29,14 +30,15 @@ import { Users, X, } from "lucide-react"; -import { api, useStore, formatTime, visibleMessages, type Bot, type Group } from "@/state/store"; +import { api, useStore, formatTime, visibleMessages, type Bot, type Group, type Team } from "@/state/store"; import { BotAvatar, InitialsAvatar } from "./Avatar"; import { stateForBot } from "@/lib/mascot"; import { useUpdaterState } from "@/lib/updater"; import { cn } from "@/lib/cn"; import { nextRename } from "@/lib/rename"; -import { downloadAllBots } from "@/lib/team-files"; +import { downloadAllBots, downloadTeam } from "@/lib/team-files"; +import { botInActiveTeam, groupInActiveTeam } from "@/lib/team-scope"; import { useDesktopCapabilities } from "./DesktopCapabilities"; import { MIN_QUERY, SearchResults } from "./SearchResults"; import { TeamLibraryPanel, type TeamImportResult } from "./TeamLibraryPanel"; @@ -368,7 +370,14 @@ function RoomContextMenu({ function NewRoomPanel({ onClose }: { onClose: () => void }) { const { state, dispatch } = useStore(); const [name, setName] = useState(""); - const [picked, setPicked] = useState>(new Set()); + const [picked, setPicked] = useState>( + () => + new Set( + state.activeTeamId + ? state.bots.filter((b) => !b.hidden && b.teamId === state.activeTeamId).map((b) => b.id) + : [], + ), + ); const bots = state.bots.filter((b) => !b.hidden); const toggle = (id: string) => setPicked((prev) => { @@ -379,17 +388,25 @@ function NewRoomPanel({ onClose }: { onClose: () => void }) { }); const create = () => { if (!picked.size) return; - dispatch({ type: "createGroup", memberIds: [...picked], name: name.trim() || undefined }); + dispatch({ + type: "createGroup", + memberIds: [...picked], + name: name.trim() || undefined, + teamId: state.activeTeamId ?? undefined, + }); track("room_created", { members: picked.size }); onClose(); }; return (
e.target === e.currentTarget && onClose()} >
-
New Room
+
New Room
void }) { ); } -/** Labeled divider between sidebar sections. Same typographic register as - * EngineGroupLabel so the sidebar reads as one system. */ -function SectionDivider({ name }: { name: string }) { - return ( -
- - {name} - - -
- ); -} - -/** Move-to-section popover: existing sections as chips (checkmark on the - * bot's current one), a create field, and a remove action. Mirrors the - * context menu's fixed positioning + dismiss-on-outside-click contract. */ -function SectionPicker({ +/** Move-to-team popover: existing teams, a create field, and a remove action. */ +function TeamPicker({ botId, anchor, onClose, + onError, }: { botId: string; anchor: MenuState; onClose: () => void; + onError: (text: string) => void; }) { const { state, dispatch } = useStore(); const [name, setName] = useState(""); + const [creating, setCreating] = useState(false); const bot = state.bots.find((b) => b.id === botId); const trimmed = name.trim(); useEffect(() => { const onDown = (e: MouseEvent) => { - if (!(e.target instanceof Element) || !e.target.closest("[data-section-picker]")) onClose(); + if (!(e.target instanceof Element) || !e.target.closest("[data-team-picker]")) onClose(); }; const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose(); window.addEventListener("mousedown", onDown); @@ -482,39 +487,60 @@ 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!))]; - const assign = (section: string) => { - dispatch({ type: "updateBot", botId, patch: { section } }); + const assign = (teamId: string) => { + dispatch({ type: "updateBot", botId, patch: { teamId } }); onClose(); }; + const createAndAssign = () => { + if (!trimmed || trimmed.length > 60 || creating) return; + setCreating(true); + api("/api/teams", { method: "POST", body: JSON.stringify({ name: trimmed, activate: false }) }) + .then( + ({ + team, + teams, + }: { + team: { id: string }; + teams: Team[]; + }) => { + dispatch({ type: "teamsListed", teams }); + dispatch({ type: "updateBot", botId, patch: { teamId: team.id } }); + onClose(); + }, + ) + .catch((cause: unknown) => { + onError(cause instanceof Error ? cause.message : String(cause)); + }) + .finally(() => setCreating(false)); + }; + const top = Math.max(8, Math.min(anchor.y, window.innerHeight - 300)); const left = Math.min(anchor.x, window.innerWidth - 260); return (
- Move to section + Move to team
- {sections.length > 0 && ( + {state.teams.length > 0 && (
- {sections.map((section) => ( + {state.teams.map((team) => ( ))}
@@ -523,8 +549,7 @@ function SectionPicker({ className="flex items-center gap-1.5 px-2.5 py-1" onSubmit={(e) => { e.preventDefault(); - if (!trimmed || trimmed.length > 60) return; - assign(trimmed); + createAndAssign(); }} > setName(e.target.value)} - placeholder="New section…" - aria-label="New section name" + placeholder="New team…" + aria-label="New team name" className="w-full rounded-lg bg-raised/70 px-2.5 py-1.5 text-[13px] text-ink placeholder:text-ink-secondary focus:outline-none" /> - {bot.section && ( + {bot.teamId && ( <>
)} @@ -566,16 +591,70 @@ function SectionPicker({ ); } +function NewTeamPanel({ + onClose, + title = "New team", + initialName = "", + confirmLabel = "Create team", + onSubmit, +}: { + onClose: () => void; + title?: string; + initialName?: string; + confirmLabel?: string; + onSubmit: (name: string) => void; +}) { + const [name, setName] = useState(initialName); + const trimmed = name.trim(); + const submit = () => { + if (!trimmed || trimmed.length > 60) return; + onSubmit(trimmed); + onClose(); + }; + return ( +
e.target === e.currentTarget && onClose()} + > +
+ + setName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") submit(); + if (e.key === "Escape") onClose(); + }} + placeholder="Team name" + 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" + /> + +
+
+ ); +} + function BotContextMenu({ menu, onClose, onArchive, - onMoveToSection, + onMoveToTeam, }: { menu: MenuState; onClose: () => void; onArchive: (bot: Bot) => void; - onMoveToSection: (botId: string) => void; + onMoveToTeam: (botId: string) => void; }) { const { state, dispatch } = useStore(); const bot = state.bots.find((b) => b.id === menu.botId); @@ -656,9 +735,9 @@ function BotContextMenu({ hint: !bot.chiefOfStaff && !canCoordinate ? "Choose a Claude or ACP engine first" : undefined, }, ), - item(, "Move to section", () => { + item(, "Move to team", () => { onClose(); - onMoveToSection(bot.id); + onMoveToTeam(bot.id); }), item(, "Mark as Unread", () => dispatch({ type: "markUnread", botId: bot.id }), @@ -974,10 +1053,13 @@ export function Sidebar({ open, onClose }: { open: boolean; onClose: () => void const { capabilities } = useDesktopCapabilities(); const importReturnRef = useRef(null); const [menu, setMenu] = useState(null); - const [sectionPicker, setSectionPicker] = useState(null); + const [teamPicker, setTeamPicker] = useState(null); const [roomMenu, setRoomMenu] = useState<{ groupId: string; x: number; y: number } | null>(null); const [plusOpen, setPlusOpen] = useState(false); + const [switcherOpen, setSwitcherOpen] = useState(false); const [newRoom, setNewRoom] = useState(false); + const [newTeam, setNewTeam] = useState(false); + const [renameTeam, setRenameTeam] = useState(false); const [teamLibraryOpen, setTeamLibraryOpen] = useState(false); const [archivedBotsOpen, setArchivedBotsOpen] = useState(false); const [exportingTeam, setExportingTeam] = useState(false); @@ -1026,6 +1108,18 @@ export function Sidebar({ open, onClose }: { open: boolean; onClose: () => void return () => document.removeEventListener("keydown", closeOnEscape); }, [open, onClose]); + useEffect(() => { + if (!switcherOpen) return; + const onKey = (event: KeyboardEvent) => { + if (event.key === "Escape") { + event.stopPropagation(); + setSwitcherOpen(false); + } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [switcherOpen]); + useEffect(() => { if (!densityOpen) return; const closeDensityMenu = (event: KeyboardEvent) => { @@ -1041,12 +1135,18 @@ export function Sidebar({ open, onClose }: { open: boolean; onClose: () => void return () => window.clearTimeout(timer); }, [teamFeedback]); - const exportAllBots = async () => { + const exportCurrentTeam = async () => { setExportingTeam(true); setTeamFeedback(null); try { - const exported = await downloadAllBots(); - track("team_exported", { members: exported.members, scope: "all_visible" }); + const active = state.teams.find((team) => team.id === state.activeTeamId); + const exported = active + ? await downloadTeam({ teamId: active.id, name: active.name }) + : await downloadAllBots(); + track("team_exported", { + members: exported.members, + scope: active ? "active_team" : "all_visible", + }); setTeamFeedback({ error: false, text: `${exported.members} bots exported` }); } catch (cause) { setTeamFeedback({ @@ -1157,7 +1257,7 @@ export function Sidebar({ open, onClose }: { open: boolean; onClose: () => void // section below the list (debounced, lands on the message). const matchingBots = state.bots - .filter((b) => !b.hidden) + .filter((b) => botInActiveTeam(b, state.activeTeamId)) .filter( (b) => !q || @@ -1166,23 +1266,23 @@ export function Sidebar({ open, onClose }: { open: boolean; onClose: () => void preview(b).toLowerCase().includes(q), ); const chiefBot = matchingBots.find((bot) => bot.chiefOfStaff); - const sectionedBots = matchingBots - .filter((bot) => !bot.chiefOfStaff && bot.section) - .sort((a, b) => Number(b.pinned ?? false) - Number(a.pinned ?? false)); const visibleBots = matchingBots - .filter((bot) => !bot.chiefOfStaff && !bot.section) + .filter((bot) => !bot.chiefOfStaff) .sort((a, b) => Number(b.pinned ?? false) - Number(a.pinned ?? false)); - // sections keep first-appearance order within the current list; a section - // whose bots 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)); + const visibleGroups = state.groups + .filter((g) => groupInActiveTeam(g, state.bots, state.activeTeamId)) + .filter((g) => !q || g.name.toLowerCase().includes(q)); const activeBotCount = state.bots.filter((bot) => !bot.hidden).length; const archivedBots = state.bots.filter((bot) => bot.hidden); const pendingTeamUndo = teamFeedback?.undo; const pendingBotUndo = teamFeedback?.restoreBot; + const activeTeam = state.teams.find((team) => team.id === state.activeTeamId) ?? null; + const switcherLabel = activeTeam?.name ?? "All bots"; + const teamCount = (teamId: string | null) => state.bots.filter((bot) => botInActiveTeam(bot, teamId)).length; + const teamUnread = (teamId: string | null) => + state.bots.some((bot) => bot.unread && botInActiveTeam(bot, teamId)); + const exportLabel = activeTeam ? `Export ${activeTeam.name}` : "Export all bots"; + const rosterEmpty = !chiefBot && visibleBots.length === 0 && visibleGroups.length === 0; return (
+
+ + {switcherOpen && ( + <> +
setSwitcherOpen(false)} /> +
+ + {state.teams.map((team) => ( + + ))} +
+ + + + {activeTeam && ( + <> + + + + )} +
+ + )} +
+ {/* Search */}
@@ -1354,8 +1574,8 @@ export function Sidebar({ open, onClose }: { open: boolean; onClose: () => void value={query} onChange={(e) => setQuery(e.target.value)} onKeyDown={(e) => e.key === "Escape" && setQuery("")} - placeholder="Search" - aria-label="Search bots and messages" + placeholder={activeTeam ? "Search this team" : "Search"} + aria-label={activeTeam ? "Search this team" : "Search bots and messages"} className="w-full bg-transparent text-[14px] text-ink placeholder:text-ink-secondary focus:outline-none" />
@@ -1364,9 +1584,14 @@ export function Sidebar({ open, onClose }: { open: boolean; onClose: () => void {/* Bot list */}
- {!chiefBot && visibleBots.length === 0 && sectionedBots.length === 0 && visibleGroups.length === 0 && q && q.length < MIN_QUERY && ( + {rosterEmpty && q && q.length < MIN_QUERY && (
Nothing matches “{query}”
)} + {rosterEmpty && !q && ( +
+ {activeTeam ? "No bots in this team yet" : "Create a bot to get started"} +
+ )} {chiefBot && (
void archiveDisabled={activeBotCount <= 1} /> ))} - {sectionNames.map((name) => ( - - {density !== "icons" && } - {sectionedBots - .filter((b) => b.section === name) - .map((b) => ( - void archiveBot(bot)} - archiveDisabled={activeBotCount <= 1} - /> - ))} - - ))} setQuery("")} />
@@ -1467,11 +1675,16 @@ export function Sidebar({ open, onClose }: { open: boolean; onClose: () => void menu={menu} onClose={() => setMenu(null)} onArchive={(bot) => void archiveBot(bot)} - onMoveToSection={(botId) => setSectionPicker({ botId, x: menu.x, y: menu.y })} + onMoveToTeam={(botId) => setTeamPicker({ botId, x: menu.x, y: menu.y })} /> )} - {sectionPicker && ( - setSectionPicker(null)} /> + {teamPicker && ( + setTeamPicker(null)} + onError={(text) => setTeamFeedback({ error: true, text })} + /> )} {roomMenu && ( void /> )} {newRoom && setNewRoom(false)} />} + {newTeam && ( + setNewTeam(false)} + onSubmit={(name) => dispatch({ type: "createTeam", name })} + /> + )} + {renameTeam && activeTeam && ( + setRenameTeam(false)} + onSubmit={(name) => dispatch({ type: "renameTeam", teamId: activeTeam.id, name })} + /> + )} {archivedBotsOpen && ( !bot.hidden).length; + const currentBotCount = state.bots.filter((bot) => botInActiveTeam(bot, state.activeTeamId)).length; const loadCatalog = useCallback(async () => { setCatalogLoading(true); @@ -212,12 +213,28 @@ export function TeamLibraryPanel({ setError(""); try { // SAFETY: this endpoint is owned by the app and returns imported bots. - const response = (await api(`/api/teams/import?mode=${importMode}`, { + const params = new URLSearchParams({ mode: importMode }); + params.set("teamId", state.activeTeamId ?? ""); + const response = (await api(`/api/teams/import?${params}`, { method: "POST", body: JSON.stringify(pending.manifest), - })) as { bots: Bot[]; archivedBots?: Bot[]; archived?: ArchivedTeamBot[] }; + })) as { + bots: Bot[]; + archivedBots?: Bot[]; + archived?: ArchivedTeamBot[]; + teams?: Team[]; + activeTeamId?: string | null; + }; for (const bot of response.archivedBots ?? []) dispatch({ type: "botPatched", bot }); for (const bot of response.bots) dispatch({ type: "botAdded", bot }); + if (response.teams) { + dispatch({ + type: "teamsHydrated", + teams: response.teams, + activeTeamId: response.activeTeamId ?? null, + }); + } + dispatch({ type: "setActiveTeam", teamId: response.activeTeamId ?? null }); const first = response.bots[0]; if (first) dispatch({ type: "select", id: first.id }); track("team_imported", { members: response.bots.length, source, mode: importMode }); diff --git a/src/lib/team-activation.test.ts b/src/lib/team-activation.test.ts new file mode 100644 index 000000000..0cc5830fd --- /dev/null +++ b/src/lib/team-activation.test.ts @@ -0,0 +1,281 @@ +import { describe, expect, it, vi } from "vitest"; + +import { createTeamActivationQueue } from "./team-activation"; + +function deferred() { + let resolve = (_value: T) => {}; + let reject = (_reason: Error) => {}; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +interface Result { + activeTeamId: string | null; +} + +function makeQueue(request: (teamId: string | null) => Promise) { + let current: string | null = null; + const applied: Array = []; + const rolledBack: Array = []; + const errors: Error[] = []; + const queue = createTeamActivationQueue({ + request, + apply: (result) => { + current = result.activeTeamId; + applied.push(result.activeTeamId); + }, + rollback: (rollbackTeamId) => { + current = rollbackTeamId; + rolledBack.push(rollbackTeamId); + }, + onError: (error) => { + errors.push(error); + }, + }); + return { + queue, + setCurrent: (teamId: string | null) => { + current = teamId; + }, + get current() { + return current; + }, + applied, + rolledBack, + errors, + }; +} + +describe("team activation queue", () => { + it("sends switches in click order so a slower first request cannot win on the server", async () => { + const started: Array = []; + const inflight: Array<{ id: string | null; wait: ReturnType> }> = []; + let server: string | null = "all"; + const { queue, setCurrent, applied } = makeQueue((id) => { + started.push(id); + const wait = deferred(); + inflight.push({ id, wait }); + return wait.promise.then(() => { + server = id; + return { activeTeamId: id }; + }); + }); + + setCurrent("eng"); + const first = queue.enqueue("eng", "all"); + await Promise.resolve(); + expect(started).toEqual(["eng"]); + expect(inflight).toHaveLength(1); + + setCurrent("mkt"); + const second = queue.enqueue("mkt", "eng"); + inflight[0]!.wait.resolve(); + await first; + await Promise.resolve(); + expect(started).toEqual(["eng", "mkt"]); + inflight[1]!.wait.resolve(); + await second; + + expect(server).toBe("mkt"); + expect(applied).toEqual(["mkt"]); + }); + + it("does not send a queued switch that a later click already superseded", async () => { + const started: Array = []; + const inflight: Array<{ id: string | null; wait: ReturnType> }> = []; + let server: string | null = "all"; + const { queue, setCurrent, applied } = makeQueue((id) => { + started.push(id); + const wait = deferred(); + inflight.push({ id, wait }); + return wait.promise.then(() => { + server = id; + return { activeTeamId: id }; + }); + }); + + setCurrent("eng"); + const first = queue.enqueue("eng", "all"); + await Promise.resolve(); + expect(started).toEqual(["eng"]); + + setCurrent("mkt"); + const second = queue.enqueue("mkt", "eng"); + setCurrent("ops"); + const third = queue.enqueue("ops", "mkt"); + + inflight[0]!.wait.resolve(); + await first; + await second; + await Promise.resolve(); + expect(started).toEqual(["eng", "ops"]); + inflight[1]!.wait.resolve(); + await third; + + expect(server).toBe("ops"); + expect(applied).toEqual(["ops"]); + }); + + it("only sends the latest switch when several clicks land before the first request starts", async () => { + const started: Array = []; + const inflight: Array<{ id: string | null; wait: ReturnType> }> = []; + let server: string | null = "all"; + const { queue, setCurrent, applied } = makeQueue((id) => { + started.push(id); + const wait = deferred(); + inflight.push({ id, wait }); + return wait.promise.then(() => { + server = id; + return { activeTeamId: id }; + }); + }); + + setCurrent("eng"); + const first = queue.enqueue("eng", "all"); + setCurrent("mkt"); + const second = queue.enqueue("mkt", "eng"); + setCurrent("ops"); + const third = queue.enqueue("ops", "mkt"); + + await first; + await second; + await Promise.resolve(); + expect(started).toEqual(["ops"]); + inflight[0]!.wait.resolve(); + await third; + + expect(server).toBe("ops"); + expect(applied).toEqual(["ops"]); + }); + + it("is busy until every queued switch has settled", async () => { + const wait = deferred(); + const queue = createTeamActivationQueue({ + request: () => wait.promise, + apply: () => {}, + rollback: () => {}, + onError: () => {}, + }); + + expect(queue.isBusy()).toBe(false); + const job = queue.enqueue("eng", null); + expect(queue.isBusy()).toBe(true); + wait.resolve({ activeTeamId: "eng" }); + await job; + expect(queue.isBusy()).toBe(false); + }); + + it("rolls a failed later switch back to the last confirmed team, not an optimistic one", async () => { + const started: Array = []; + const inflight: Array<{ id: string | null; wait: ReturnType> }> = []; + const harness = makeQueue((id) => { + started.push(id); + const wait = deferred(); + inflight.push({ id, wait }); + return wait.promise.then(() => { + throw new Error(`${id} failed`); + }); + }); + const { queue, setCurrent, applied, rolledBack, errors } = harness; + + setCurrent("eng"); + const first = queue.enqueue("eng", null); + await Promise.resolve(); + expect(started).toEqual(["eng"]); + + setCurrent("mkt"); + const second = queue.enqueue("mkt", "eng"); + inflight[0]!.wait.resolve(); + await first; + expect(applied).toEqual([]); + expect(rolledBack).toEqual([]); + expect(errors).toEqual([]); + + await Promise.resolve(); + expect(started).toEqual(["eng", "mkt"]); + inflight[1]!.wait.resolve(); + await second; + expect(errors).toHaveLength(1); + expect(rolledBack).toEqual([null]); + expect(harness.current).toBeNull(); + }); + + it("does not let a failed first A rewind A → All bots → A", async () => { + const inflight: Array<{ id: string | null; wait: ReturnType> }> = []; + let server: string | null = null; + let calls = 0; + const harness = makeQueue((id) => { + const wait = deferred(); + const call = ++calls; + inflight.push({ id, wait }); + return wait.promise.then(() => { + if (call === 1) throw new Error("first A failed"); + server = id; + return { activeTeamId: id }; + }); + }); + const { queue, setCurrent, applied, rolledBack, errors } = harness; + + setCurrent("eng"); + const first = queue.enqueue("eng", null); + await Promise.resolve(); + setCurrent(null); + const allBots = queue.enqueue(null, "eng"); + setCurrent("eng"); + const again = queue.enqueue("eng", null); + + inflight[0]!.wait.resolve(); + await first; + expect(rolledBack).toEqual([]); + expect(errors).toEqual([]); + + await allBots; + await Promise.resolve(); + inflight[1]!.wait.resolve(); + await again; + + expect(server).toBe("eng"); + expect(harness.current).toBe("eng"); + expect(applied).toEqual(["eng"]); + expect(rolledBack).toEqual([]); + }); + + it("keeps a later confirmed switch when an earlier request fails", async () => { + const onError = vi.fn(); + let current: string | null = "eng"; + const first = deferred(); + const second = deferred(); + const requests: Array = []; + const queue = createTeamActivationQueue({ + request: (id) => { + requests.push(id); + return id === "eng" ? first.promise : second.promise; + }, + apply: (result) => { + current = result.activeTeamId; + }, + rollback: (rollbackTeamId) => { + current = rollbackTeamId; + }, + onError, + }); + + const firstJob = queue.enqueue("eng", null); + await Promise.resolve(); + current = "mkt"; + const secondJob = queue.enqueue("mkt", "eng"); + + first.reject(new Error("eng failed")); + await firstJob; + expect(onError).not.toHaveBeenCalled(); + expect(current).toBe("mkt"); + + second.resolve({ activeTeamId: "mkt" }); + await secondJob; + expect(current).toBe("mkt"); + expect(requests).toEqual(["eng", "mkt"]); + }); +}); diff --git a/src/lib/team-activation.ts b/src/lib/team-activation.ts new file mode 100644 index 000000000..26d568256 --- /dev/null +++ b/src/lib/team-activation.ts @@ -0,0 +1,65 @@ +/** One /api/teams/active at a time, in click order. Each click gets an + * operation id so a repeated pick of the same team cannot be applied or + * rolled back by an earlier request. A superseded click is not sent at + * all, so creating or importing a team can enqueue as the last write and + * a slower earlier switch cannot land on the server after it. Rollback + * uses the last team the server actually accepted, not an optimistic + * in-between click. */ + +export interface TeamActivationQueue { + enqueue: (requestedTeamId: string | null, baselineTeamId: string | null) => Promise; + isBusy: () => boolean; +} + +export interface TeamActivationQueueOptions { + request: (teamId: string | null) => Promise; + apply: (result: T) => void; + rollback: (teamId: string | null) => void; + onError: (error: Error) => void; +} + +export function createTeamActivationQueue(opts: TeamActivationQueueOptions): TeamActivationQueue { + let tail: Promise = Promise.resolve(); + let latestOp = 0; + let pending = 0; + let confirmedTeamId: string | null = null; + let hasConfirmed = false; + + const run = async (opId: number, requestedTeamId: string | null) => { + if (opId !== latestOp) return; + try { + const result = await opts.request(requestedTeamId); + confirmedTeamId = requestedTeamId; + hasConfirmed = true; + if (opId !== latestOp) return; + opts.apply(result); + } catch (caught) { + if (opId !== latestOp) return; + opts.onError(caught instanceof Error ? caught : new Error(String(caught))); + opts.rollback(confirmedTeamId); + } + }; + + return { + enqueue(requestedTeamId: string | null, baselineTeamId: string | null) { + const opId = ++latestOp; + pending++; + if (!hasConfirmed) { + confirmedTeamId = baselineTeamId; + hasConfirmed = true; + } + const job = () => run(opId, requestedTeamId); + const next = tail.then(job, job).finally(() => { + pending--; + }); + tail = next.then( + () => undefined, + () => undefined, + ); + return next; + }, + isBusy() { + return pending > 0; + }, + }; +} diff --git a/src/lib/team-files.ts b/src/lib/team-files.ts index 09487bb94..5febcd43f 100644 --- a/src/lib/team-files.ts +++ b/src/lib/team-files.ts @@ -30,9 +30,18 @@ function downloadManifest(manifest: ExportedTeam): { name: string; members: numb /** Export every active sidebar bot in one click. The server excludes hidden bots. */ export async function downloadAllBots(): Promise<{ name: string; members: number }> { + return downloadTeam(); +} + +/** Export one named team, or every visible bot when teamId is omitted. */ +export async function downloadTeam(opts?: { + teamId?: string; + name?: string; +}): Promise<{ name: string; members: number }> { + // SAFETY: /api/teams/export returns a parsed team manifest or throws. const manifest = (await api("/api/teams/export", { method: "POST", - body: "{}", + body: JSON.stringify(opts ?? {}), })) as ExportedTeam; return downloadManifest(manifest); } diff --git a/src/lib/team-scope.test.ts b/src/lib/team-scope.test.ts new file mode 100644 index 000000000..121a633f8 --- /dev/null +++ b/src/lib/team-scope.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; + +import { + botInActiveTeam, + firstVisibleSelection, + groupInActiveTeam, + isCurrentTeamActivation, + searchHitInActiveTeam, +} from "./team-scope"; + +const bots = [ + { id: "atlas", teamId: "eng", chiefOfStaff: true }, + { id: "scout", teamId: "eng" }, + { id: "copy", teamId: "mkt" }, + { id: "spare" }, + { id: "ghost", teamId: "eng", hidden: true }, +]; + +const groups = [ + { id: "standup", teamId: "eng", memberIds: ["scout"] }, + { id: "campaign", teamId: "mkt", memberIds: ["copy"] }, + { id: "dm", dm: true, memberIds: ["scout", "atlas"] }, + { id: "mixed", memberIds: ["scout", "copy"] }, +]; + +describe("team scope", () => { + it("All bots shows everyone except archived", () => { + expect(bots.filter((bot) => botInActiveTeam(bot, null)).map((bot) => bot.id)).toEqual([ + "atlas", + "scout", + "copy", + "spare", + ]); + expect(groups.filter((group) => groupInActiveTeam(group, bots, null)).map((group) => group.id)).toEqual([ + "standup", + "campaign", + "dm", + "mixed", + ]); + }); + + it("a named team is a flat roster of its bots, its rooms, and DMs wholly inside it", () => { + expect(bots.filter((bot) => botInActiveTeam(bot, "eng")).map((bot) => bot.id)).toEqual(["atlas", "scout"]); + expect(groups.filter((group) => groupInActiveTeam(group, bots, "eng")).map((group) => group.id)).toEqual([ + "standup", + "dm", + ]); + }); + + it("keeps the current chat if it still belongs, otherwise the chief then a room then a bot", () => { + expect(firstVisibleSelection(bots, groups, "eng", "scout")).toBe("scout"); + expect(firstVisibleSelection(bots, groups, "eng", "copy")).toBe("atlas"); + expect(firstVisibleSelection(bots, groups, "mkt", "scout")).toBe("campaign"); + }); + + it("a failed earlier team switch does not rewind a later one", () => { + expect(isCurrentTeamActivation("mkt", "eng")).toBe(false); + expect(isCurrentTeamActivation("eng", "eng")).toBe(true); + expect(isCurrentTeamActivation(null, null)).toBe(true); + expect(isCurrentTeamActivation("eng", null)).toBe(false); + }); + + it("drops search hits from other teams", () => { + expect(searchHitInActiveTeam({ botId: "scout" }, bots, groups, "eng")).toBe(true); + expect(searchHitInActiveTeam({ botId: "copy" }, bots, groups, "eng")).toBe(false); + expect(searchHitInActiveTeam({ groupId: "standup" }, bots, groups, "eng")).toBe(true); + expect(searchHitInActiveTeam({ groupId: "campaign" }, bots, groups, "eng")).toBe(false); + expect(searchHitInActiveTeam({ botId: "scout" }, bots, groups, null)).toBe(true); + }); +}); diff --git a/src/lib/team-scope.ts b/src/lib/team-scope.ts new file mode 100644 index 000000000..3ea599265 --- /dev/null +++ b/src/lib/team-scope.ts @@ -0,0 +1,67 @@ +/** The switcher filters the existing sidebar list. Null = All bots. */ + +export function botInActiveTeam( + bot: { teamId?: string | null; hidden?: boolean }, + teamId: string | null, +): boolean { + if (bot.hidden) return false; + if (!teamId) return true; + return bot.teamId === teamId; +} + +export function groupInActiveTeam( + group: { teamId?: string | null; memberIds: string[]; dm?: boolean }, + bots: Array<{ id: string; teamId?: string | null; hidden?: boolean }>, + teamId: string | null, +): boolean { + if (!teamId) return true; + if (group.teamId) return group.teamId === teamId; + const members = group.memberIds + .map((id) => bots.find((bot) => bot.id === id)) + .filter((bot): bot is NonNullable => bot != null && !bot.hidden); + return members.length > 0 && members.every((bot) => bot.teamId === teamId); +} + +/** A late or failed /api/teams/active response must not rewind a newer switch. */ +export function isCurrentTeamActivation( + currentActiveTeamId: string | null, + requestedTeamId: string | null, +): boolean { + return currentActiveTeamId === requestedTeamId; +} + +export function firstVisibleSelection( + bots: Array<{ id: string; teamId?: string | null; hidden?: boolean; chiefOfStaff?: boolean }>, + groups: Array<{ id: string; teamId?: string | null; memberIds: string[]; dm?: boolean }>, + teamId: string | null, + currentId?: string, +): string { + const visibleBots = bots.filter((bot) => botInActiveTeam(bot, teamId)); + const visibleGroups = groups.filter((group) => groupInActiveTeam(group, bots, teamId)); + if ( + currentId && + (visibleBots.some((bot) => bot.id === currentId) || visibleGroups.some((group) => group.id === currentId)) + ) { + return currentId; + } + const chief = visibleBots.find((bot) => bot.chiefOfStaff); + return chief?.id ?? visibleGroups[0]?.id ?? visibleBots[0]?.id ?? ""; +} + +export function searchHitInActiveTeam( + hit: { botId?: string; groupId?: string }, + bots: Array<{ id: string; teamId?: string | null; hidden?: boolean }>, + groups: Array<{ id: string; teamId?: string | null; memberIds: string[]; dm?: boolean }>, + teamId: string | null, +): boolean { + if (!teamId) return true; + if (hit.botId) { + const bot = bots.find((candidate) => candidate.id === hit.botId); + return Boolean(bot && botInActiveTeam(bot, teamId)); + } + if (hit.groupId) { + const group = groups.find((candidate) => candidate.id === hit.groupId); + return Boolean(group && groupInActiveTeam(group, bots, teamId)); + } + return false; +} diff --git a/src/state/bot-patch-queue.ts b/src/state/bot-patch-queue.ts index 00c37982d..4d1eae930 100644 --- a/src/state/bot-patch-queue.ts +++ b/src/state/bot-patch-queue.ts @@ -20,6 +20,7 @@ export type BotUpdatePatch = Partial< | "pinned" | "hidden" | "section" + | "teamId" | "pinnedMessageId" | "chiefOfStaff" | "approvePeerComms" diff --git a/src/state/store.test.ts b/src/state/store.test.ts index 72e676087..8262a593d 100644 --- a/src/state/store.test.ts +++ b/src/state/store.test.ts @@ -105,3 +105,79 @@ describe("cross-client bot creation", () => { expect(greeted.bots[0]?.messages).toEqual([greeting]); }); }); + +describe("team switcher", () => { + const bot = (id: string, teamId?: string): Bot => ({ + id, + threadId: `${id}-thread`, + name: id, + title: "", + description: "", + notifications: true, + color: "green", + unread: false, + modelSelection: { instanceId: "codex", model: "default" }, + messages: [], + ...(teamId ? { teamId } : {}), + }); + + it("hydrate and setActiveTeam keep the current chat when it still belongs", () => { + const scout = bot("scout", "eng"); + const copy = bot("copy", "mkt"); + const hydrated = reducer(initialState, { + type: "hydrate", + bots: [scout, copy], + groups: [], + teams: [ + { id: "eng", name: "Engineering", createdAt: 1 }, + { id: "mkt", name: "Marketing", createdAt: 2 }, + ], + activeTeamId: "eng", + computerControl: {}, + }); + expect(hydrated.selectedId).toBe("scout"); + expect(hydrated.activeTeamId).toBe("eng"); + + const selected = reducer(hydrated, { type: "select", id: "scout" }); + const switched = reducer(selected, { type: "setActiveTeam", teamId: "mkt" }); + expect(switched.activeTeamId).toBe("mkt"); + expect(switched.selectedId).toBe("copy"); + }); + + it("moves selection when the open bot leaves the active team", () => { + const scout = bot("scout", "eng"); + const tester = bot("tester", "eng"); + const start = reducer(initialState, { + type: "hydrate", + bots: [scout, tester], + groups: [], + teams: [{ id: "eng", name: "Engineering", createdAt: 1 }], + activeTeamId: "eng", + computerControl: {}, + }); + const selected = reducer(start, { type: "select", id: "scout" }); + const moved = reducer(selected, { type: "updateBot", botId: "scout", patch: { teamId: "" } }); + expect(moved.selectedId).toBe("tester"); + }); + + it("teamsListed refreshes names without rewinding the active team", () => { + const start = reducer(initialState, { + type: "hydrate", + bots: [bot("scout", "eng")], + groups: [], + teams: [{ id: "eng", name: "Engineering", createdAt: 1 }], + activeTeamId: "eng", + computerControl: {}, + }); + const listed = reducer(start, { + type: "teamsListed", + teams: [ + { id: "eng", name: "Platform", createdAt: 1 }, + { id: "mkt", name: "Marketing", createdAt: 2 }, + ], + }); + expect(listed.activeTeamId).toBe("eng"); + expect(listed.selectedId).toBe("scout"); + expect(listed.teams.map((team) => team.name)).toEqual(["Platform", "Marketing"]); + }); +}); diff --git a/src/state/store.tsx b/src/state/store.tsx index 812d1fd0f..bdc39a3e3 100644 --- a/src/state/store.tsx +++ b/src/state/store.tsx @@ -21,6 +21,8 @@ import type { WebhookAttempt, WebhookIngressStatus, WebhookTrigger } from "@/lib import { currentCall } from "@/lib/call"; import { showNotification, type NotificationTarget } from "@/lib/notify"; import { speaker } from "@/lib/tts"; +import { createTeamActivationQueue } from "@/lib/team-activation"; +import { firstVisibleSelection } from "@/lib/team-scope"; import { createBotPatchQueue, type BotUpdatePatch } from "./bot-patch-queue"; export type { MausColor } from "@/lib/mascot"; @@ -111,9 +113,17 @@ export interface Group { pinnedCwd?: string | null; /** the one message pinned to the top of this room's transcript */ pinnedMessageId?: string; + /** Sidebar team this room belongs to; absent = unassigned. */ + teamId?: string | null; messages: Message[]; } +export interface Team { + id: string; + name: string; + createdAt: number; +} + export interface ModelSelection { instanceId: string; model: string; @@ -180,6 +190,8 @@ export interface Bot { hidden?: boolean; /** Sidebar section this bot renders under; absent = unsectioned. */ section?: string; + /** Sidebar team this bot belongs to; absent = unassigned. */ + teamId?: string | null; /** the one message pinned to the top of this bot's active thread */ pinnedMessageId?: string; /** The workspace's one primary coordinator. */ @@ -318,6 +330,9 @@ export type AppSettingsSection = export interface AppState { bots: Bot[]; groups: Group[]; + teams: Team[]; + /** null = All bots. */ + activeTeamId: string | null; instances: InstanceInfo[]; config: ConfigStatus | null; /** selected chat — a bot id OR a group id */ @@ -362,8 +377,16 @@ export type Action = type: "hydrate"; bots: Bot[]; groups: Group[]; + teams?: Team[]; + activeTeamId?: string | null; computerControl: Record; } + | { type: "teamsHydrated"; teams: Team[]; activeTeamId: string | null } + | { type: "teamsListed"; teams: Team[] } + | { type: "createTeam"; name: string } + | { type: "renameTeam"; teamId: string; name: string } + | { type: "deleteTeam"; teamId: string } + | { type: "setActiveTeam"; teamId: string | null } | { type: "showRoutines" } | { type: "routinesHydrated"; routines: Routine[]; runs: RoutineRun[] } | { type: "routinePatched"; routine: Routine } @@ -381,7 +404,7 @@ export type Action = | { type: "markRoutineRunSeen"; runId: string } | { type: "groupPatched"; group: Partial & { id: string } } | { type: "groupDeleted"; groupId: string } - | { type: "createGroup"; memberIds: string[]; name?: string } + | { type: "createGroup"; memberIds: string[]; name?: string; teamId?: string } | { type: "sendGroup"; groupId: string; text: string } | { type: "patchGroup"; @@ -498,17 +521,33 @@ function patchCard(state: AppState, botId: string, messageId: string, patch: Par export function reducer(state: AppState, action: Action): AppState { switch (action.type) { case "hydrate": { - const known = (id: string) => action.bots.some((b) => b.id === id) || action.groups.some((g) => g.id === id); - const selectedId = - state.selectedId && known(state.selectedId) ? state.selectedId : (action.bots[0]?.id ?? ""); + const teams = action.teams ?? state.teams; + const activeTeamId = action.activeTeamId !== undefined ? action.activeTeamId : state.activeTeamId; + const selectedId = firstVisibleSelection(action.bots, action.groups, activeTeamId, state.selectedId); return { ...state, bots: action.bots, groups: action.groups, + teams, + activeTeamId, computerControl: action.computerControl, selectedId, }; } + case "teamsHydrated": { + const selectedId = firstVisibleSelection(state.bots, state.groups, action.activeTeamId, state.selectedId); + return { ...state, teams: action.teams, activeTeamId: action.activeTeamId, selectedId }; + } + case "teamsListed": + return { ...state, teams: action.teams }; + case "setActiveTeam": { + const selectedId = firstVisibleSelection(state.bots, state.groups, action.teamId, state.selectedId); + return { ...state, activeTeamId: action.teamId, selectedId, activeView: "chat" }; + } + case "createTeam": + case "renameTeam": + case "deleteTeam": + return state; case "showRoutines": return { ...state, @@ -652,7 +691,7 @@ export function reducer(state: AppState, action: Action): AppState { : animated; const switchedThread = typeof action.bot.threadId === "string" && action.bot.threadId !== before.threadId; - return updateBot(next, action.bot.id, (b) => ({ + const patched = updateBot(next, action.bot.id, (b) => ({ ...b, ...action.bot, // Ordinary bot patches omit messages and must preserve the current @@ -664,6 +703,16 @@ export function reducer(state: AppState, action: Action): AppState { ? action.bot.messages : b.messages, })); + if ((before.teamId ?? null) === (action.bot.teamId ?? null)) return patched; + return { + ...patched, + selectedId: firstVisibleSelection( + patched.bots, + patched.groups, + patched.activeTeamId, + patched.selectedId, + ), + }; } case "messageAdded": { const bot = state.bots.find((b) => b.threadId === action.threadId); @@ -845,7 +894,17 @@ export function reducer(state: AppState, action: Action): AppState { } : animated; const { acknowledgeLocalAuto: _ack, ...botPatch } = action.patch; - return updateBot(next, action.botId, (b) => ({ ...b, ...botPatch })); + const patched = updateBot(next, action.botId, (b) => ({ ...b, ...botPatch })); + if (!Object.prototype.hasOwnProperty.call(action.patch, "teamId")) return patched; + return { + ...patched, + selectedId: firstVisibleSelection( + patched.bots, + patched.groups, + patched.activeTeamId, + patched.selectedId, + ), + }; } case "threadActive": { const bot = state.bots.find((b) => b.threadId === action.threadId); @@ -925,6 +984,8 @@ const MAX_KEPT_SCREEN_FRAMES = 8; export const initialState: AppState = { bots: [], groups: [], + teams: [], + activeTeamId: null, instances: [], config: null, selectedId: "", @@ -1065,6 +1126,29 @@ export function StoreProvider({ children }: { children: ReactNode }) { return () => botPatchQueue.dispose(); }, [botPatchQueue]); + const teamActivationQueue = useMemo( + () => + createTeamActivationQueue<{ teams: Team[]; activeTeamId: string | null }>({ + request: (id) => + api("/api/teams/active", { + method: "POST", + body: JSON.stringify({ id }), + }), + apply: ({ teams, activeTeamId }) => rawDispatch({ type: "teamsHydrated", teams, activeTeamId }), + rollback: (rollbackTeamId) => + rawDispatch({ + type: "teamsHydrated", + teams: stateRef.current.teams, + activeTeamId: rollbackTeamId, + }), + onError: (error) => { + rawDispatch({ type: "error", message: error.message }); + setTimeout(() => rawDispatch({ type: "error", message: null }), 6000); + }, + }), + [], + ); + const dispatch = useMemo(() => { const showError = (e: unknown) => { rawDispatch({ type: "error", message: e instanceof Error ? e.message : String(e) }); @@ -1078,8 +1162,13 @@ export function StoreProvider({ children }: { children: ReactNode }) { body: JSON.stringify(patch), }).catch(() => {}); }; + const claimActiveTeam = (teams: Team[], activeTeamId: string | null) => { + rawDispatch({ type: "teamsHydrated", teams, activeTeamId }); + void teamActivationQueue.enqueue(activeTeamId, stateRef.current.activeTeamId); + }; const wrapped: React.Dispatch = (action) => { + const previousActiveTeamId = stateRef.current.activeTeamId; const botBeforeUpdate = action.type === "updateBot" ? stateRef.current.bots.find((candidate) => candidate.id === action.botId) @@ -1192,7 +1281,12 @@ export function StoreProvider({ children }: { children: ReactNode }) { break; } case "newBot": - api("/api/bots", { method: "POST" }) + api("/api/bots", { + method: "POST", + body: JSON.stringify( + stateRef.current.activeTeamId ? { teamId: stateRef.current.activeTeamId } : {}, + ), + }) .then(({ bot }) => rawDispatch({ type: "botAdded", bot })) .catch(showError); break; @@ -1209,6 +1303,7 @@ export function StoreProvider({ children }: { children: ReactNode }) { cloudBackend: source.cloudBackend, avatarUrl: source.avatarUrl, avatarCrop: source.avatarCrop, + ...(source.teamId ? { teamId: source.teamId } : {}), }; api("/api/bots", { method: "POST" }) .then(({ bot }) => @@ -1245,7 +1340,11 @@ export function StoreProvider({ children }: { children: ReactNode }) { case "createGroup": api(`/api/groups`, { method: "POST", - body: JSON.stringify({ memberIds: action.memberIds, name: action.name }), + body: JSON.stringify({ + memberIds: action.memberIds, + name: action.name, + ...(action.teamId ? { teamId: action.teamId } : {}), + }), }) .then(({ group }) => { rawDispatch({ type: "groupPatched", group }); @@ -1309,6 +1408,31 @@ export function StoreProvider({ children }: { children: ReactNode }) { case "interruptGroup": api(`/api/groups/${action.groupId}/interrupt`, { method: "POST" }).catch(showError); break; + case "createTeam": + api("/api/teams", { method: "POST", body: JSON.stringify({ name: action.name }) }) + .then(({ teams, activeTeamId }: { teams: Team[]; activeTeamId: string | null }) => + claimActiveTeam(teams, activeTeamId), + ) + .catch(showError); + break; + case "renameTeam": + api(`/api/teams/${action.teamId}`, { + method: "PATCH", + body: JSON.stringify({ name: action.name }), + }) + .then(({ teams }: { teams: Team[] }) => rawDispatch({ type: "teamsListed", teams })) + .catch(showError); + break; + case "deleteTeam": + api(`/api/teams/${action.teamId}`, { method: "DELETE" }) + .then(({ teams, activeTeamId }: { teams: Team[]; activeTeamId: string | null }) => + claimActiveTeam(teams, activeTeamId), + ) + .catch(showError); + break; + case "setActiveTeam": + void teamActivationQueue.enqueue(action.teamId, previousActiveTeamId); + break; case "updateBot": { if (botBeforeUpdate) { botPatchQueue.enqueue(action.botId, action.patch, botBeforeUpdate); @@ -1320,7 +1444,7 @@ export function StoreProvider({ children }: { children: ReactNode }) { } }; return wrapped; - }, [botPatchQueue]); + }, [botPatchQueue, teamActivationQueue]); // ── initial load + SSE fold ────────────────────────────────────────── useEffect(() => { @@ -1328,11 +1452,13 @@ export function StoreProvider({ children }: { children: ReactNode }) { const loadAll = () => Promise.all([ api("/api/bots") - .then(({ bots, groups, computerControl }) => + .then(({ bots, groups, teams, activeTeamId, computerControl }) => alive && rawDispatch({ type: "hydrate", bots, groups: groups ?? [], + teams: teams ?? [], + activeTeamId: activeTeamId ?? null, computerControl: computerControl ?? {}, })) .catch(() => {}), @@ -1467,6 +1593,17 @@ export function StoreProvider({ children }: { children: ReactNode }) { case "group.deleted": rawDispatch({ type: "groupDeleted", groupId: frame.groupId }); break; + case "teams": + rawDispatch({ + type: "teamsHydrated", + teams: Array.isArray(frame.teams) ? frame.teams : [], + activeTeamId: teamActivationQueue.isBusy() + ? stateRef.current.activeTeamId + : typeof frame.activeTeamId === "string" + ? frame.activeTeamId + : null, + }); + break; case "routine": rawDispatch({ type: "routinePatched", routine: frame.routine }); break;