diff --git a/server/index.test.ts b/server/index.test.ts index 7c8b2b282..d73d52936 100644 --- a/server/index.test.ts +++ b/server/index.test.ts @@ -294,6 +294,31 @@ describe("harness HTTP API", () => { const invalid = await api("POST", "/api/teams/import", { ...exported.body, version: 3 }); 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 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).sort()).toEqual( + beforeReplace.map((bot: { id: string }) => bot.id).sort(), + ); + 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(), + ); + expect((await api("GET", "/api/bots")).body.groups).toHaveLength(roomsBefore); + + // Put the shared test harness back exactly as it was before exercising + // replace. This mirrors the UI's Undo action and preserves the seeded bot. + for (const bot of replaced.body.bots) await api("DELETE", `/api/bots/${bot.id}`); + for (const bot of replaced.body.archived.filter((item: { chiefOfStaff: boolean }) => !item.chiefOfStaff)) { + await api("PATCH", `/api/bots/${bot.id}`, { hidden: false }); + } + const previousChief = replaced.body.archived.find((bot: { chiefOfStaff: boolean }) => bot.chiefOfStaff); + if (previousChief) await api("PATCH", `/api/bots/${previousChief.id}`, { hidden: false, chiefOfStaff: true }); for (const bot of [first, second, hidden, ...imported.body.bots]) { expect((await api("DELETE", `/api/bots/${bot.id}`)).status).toBe(200); diff --git a/server/index.ts b/server/index.ts index bf9de4e3f..a4f6c5e23 100644 --- a/server/index.ts +++ b/server/index.ts @@ -1911,6 +1911,10 @@ const server = createServer(async (req, res) => { } } if (method === "POST" && path === "/api/teams/import") { + const importMode = url.searchParams.get("mode") ?? "add"; + if (importMode !== "add" && importMode !== "replace") { + return json(res, 400, { error: "Team import mode must be add or replace" }); + } const body = await readBody(req); let manifest; try { @@ -1919,6 +1923,14 @@ const server = createServer(async (req, res) => { return json(res, 400, { error: error instanceof Error ? error.message : "Invalid team file" }); } + // 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. + const archived = importMode === "replace" + ? store.bots + .filter((bot) => !bot.hidden) + .map((bot) => ({ id: bot.id, chiefOfStaff: Boolean(bot.chiefOfStaff) })) + : []; const importedBots: ReturnType[] = []; try { const selection = await defaultSelection(); @@ -1934,9 +1946,14 @@ const server = createServer(async (req, res) => { }), ); } + const archivedBots = archived.flatMap(({ id }) => { + const bot = store.patchBot(id, { hidden: true, chiefOfStaff: false }); + return bot ? [publicBot(bot)] : []; + }); const publicBots = importedBots.map(publicBot); + for (const bot of archivedBots) broadcast({ kind: "bot", bot }); for (const bot of publicBots) broadcast({ kind: "bot", bot }); - return json(res, 201, { bots: publicBots }); + return json(res, 201, { bots: publicBots, archivedBots, archived }); } catch (error) { for (const bot of importedBots) store.deleteBot(bot.id); throw error; diff --git a/src/components/PluginsPanel.tsx b/src/components/PluginsPanel.tsx index 3565ab102..08492566c 100644 --- a/src/components/PluginsPanel.tsx +++ b/src/components/PluginsPanel.tsx @@ -3,7 +3,7 @@ // Composio API key is configured, a curated set otherwise. Icons resolve // logo → favicon → monogram. import { useCallback, useEffect, useRef, useState } from "react"; -import { Loader2, RefreshCw, X } from "lucide-react"; +import { Check, Loader2, RefreshCw, Search, X } from "lucide-react"; import { api, useStore } from "@/state/store"; import { cn } from "@/lib/cn"; @@ -39,20 +39,20 @@ function ServiceIcon({ card }: { card: ToolkitCard }) { // 0 = official logo, 1 = favicon by domain, 2 = monogram const [stage, setStage] = useState(card.logo ? 0 : card.domain ? 1 : 2); if (stage === 0 && card.logo) { - return setStage(1)} />; + return setStage(1)} />; } if (stage === 1 && card.domain) { return ( setStage(2)} /> ); } return ( -
+
{card.label.slice(0, 1).toUpperCase()}
); @@ -70,6 +70,7 @@ export function PluginsPanel() { const [refreshing, setRefreshing] = useState(false); const [error, setError] = useState(null); const [search, setSearch] = useState(""); + const [tab, setTab] = useState<"marketplace" | "connected">("marketplace"); const pollTimers = useRef(new Map>()); const statusGenerations = useRef(new Map()); @@ -225,14 +226,17 @@ export function PluginsPanel() { .finally(() => setBusySlug(null)); }; - const visible = (cards ?? []).filter( + const matching = (cards ?? []).filter( (c) => !search || `${c.label} ${c.slug} ${c.blurb}`.toLowerCase().includes(search.toLowerCase()), ); + const visible = matching.filter((card) => tab === "marketplace" || status[card.slug]?.connected); + const connectedCount = Object.values(status).filter((service) => service.connected).length; + const close = () => dispatch({ type: "togglePlugins", open: false }); return (
dispatch({ type: "togglePlugins", open: false })} + className="fixed inset-0 z-50 flex items-center justify-center bg-black/55 p-4 backdrop-blur-[2px] sm:p-6" + onMouseDown={(event) => event.target === event.currentTarget && close()} >
e.stopPropagation()} + className="animate-pop-in flex h-[min(780px,calc(100dvh-2rem))] w-full max-w-[1040px] flex-col overflow-hidden rounded-[24px] border border-hairline/50 bg-panel shadow-2xl shadow-black/50" > -
-
Connected apps
+
+
+

Plugins

+

Connect the apps your bots can use.

+
-
-
- Apps your bots can use through Composio. + + +
+
+ + +
+
{!configured && ( -
- Connect your own Composio project first —{" "} +
+ Add your Composio project key to connect apps.{" "} {" "} - to connect apps. + Open settings +
)} {configured && source === "curated" && ( -
- Showing a curated set.{" "} +
+ Showing featured apps.{" "} {" "} - to browse the full catalog. + for the full catalog.
)} - {error &&
{error}
} + {error &&
{error}
} - setSearch(e.target.value)} - placeholder="Search apps" - className="mt-3 w-full rounded-lg border border-hairline/40 bg-inset px-3 py-2 text-[13px] text-ink placeholder:text-ink-secondary focus:border-hairline focus:outline-none" - /> - -
+
{cards === null ? ( -
+
Loading catalog…
) : ( - visible.map((card, i) => { +
+
+ {tab === "connected" ? "Your connections" : search ? "Search results" : "Available apps"} +
+
+ {visible.map((card) => { const serviceStatus = status[card.slug]; const connected = serviceStatus?.connected; const pending = serviceStatus?.pending; @@ -320,18 +357,12 @@ export function PluginsPanel() { return (
0 && "border-t border-hairline/40", - )} + className="flex min-h-[88px] items-center gap-3 border-b border-hairline/35 px-1 py-4" >
-
- {card.label} - {connected && } -
-
+
{card.label}
+
{pending ? "Finish setup in your browser" : failed ? "Authorization expired — try again" : card.blurb}
@@ -345,16 +376,17 @@ export function PluginsPanel() { } else void connect(card.slug); }} className={cn( - "w-[92px] rounded-lg py-1.5 text-[13px] disabled:opacity-50", + "flex min-w-[88px] items-center justify-center gap-1.5 rounded-full px-3 py-2 text-[12.5px] transition-colors disabled:opacity-40", connected - ? "bg-raised text-ink-secondary hover:text-danger" + ? "bg-transparent text-success hover:bg-danger/10 hover:text-danger" : "bg-raised text-ink hover:bg-raised-hover", )} + title={connected ? `Disconnect ${card.label}` : undefined} > {busy ? ( ) : connected ? ( - "Disconnect" + <> Connected ) : pending ? ( "Continue" ) : failed ? ( @@ -365,10 +397,19 @@ export function PluginsPanel() {
); - }) + })} +
+
)} {cards !== null && visible.length === 0 && ( -
No apps match.
+
+
+ {tab === "connected" ? "No connected plugins yet" : "No plugins found"} +
+
+ {tab === "connected" ? "Connect an app from Marketplace and it will appear here." : "Try a different search."} +
+
)}
diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 6811b1696..70905ded1 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -2,6 +2,7 @@ import { track } from "@/lib/analytics"; import { useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { + Archive, ArrowDownToLine, BellDot, Bot as BotIcon, @@ -10,7 +11,6 @@ import { ClipboardCopy, Copy, Crown, - EyeOff, FolderPlus, Library, Loader2, @@ -24,15 +24,16 @@ import { Puzzle, Trash2, Users, + X, } from "lucide-react"; -import { useStore, formatTime, visibleMessages, type Bot, type Group } from "@/state/store"; +import { api, useStore, formatTime, visibleMessages, type Bot, type Group } from "@/state/store"; import { MausAvatar, InitialsAvatar } from "./Avatar"; import { stateForBot } from "@/lib/mascot"; import { useUpdaterState } from "@/lib/updater"; import { cn } from "@/lib/cn"; import { downloadAllBots } from "@/lib/team-files"; import { useDesktopCapabilities } from "./DesktopCapabilities"; -import { TeamLibraryPanel } from "./TeamLibraryPanel"; +import { TeamLibraryPanel, type TeamImportResult } from "./TeamLibraryPanel"; import { RenameTitle } from "./RenameTitle"; /** "Milind Soni" → "MS", "milind" → "M", "you@x.dev" → "Y", unset → "?" */ @@ -347,7 +348,15 @@ function NewRoomPanel({ onClose }: { onClose: () => void }) { ); } -function BotContextMenu({ menu, onClose }: { menu: MenuState; onClose: () => void }) { +function BotContextMenu({ + menu, + onClose, + onArchive, +}: { + menu: MenuState; + onClose: () => void; + onArchive: (bot: Bot) => void; +}) { const { state, dispatch } = useStore(); const bot = state.bots.find((b) => b.id === menu.botId); @@ -369,6 +378,13 @@ function BotContextMenu({ menu, onClose }: { menu: MenuState; onClose: () => voi if (!bot) return null; const engine = state.instances.find((instance) => instance.instanceId === bot.modelSelection.instanceId); const canCoordinate = engine?.capabilities?.agentsMcp === true; + const visibleBotCount = state.bots.filter((candidate) => !candidate.hidden).length; + const archiveBlocked = Boolean(bot.chiefOfStaff) || visibleBotCount <= 1; + const archiveHint = bot.chiefOfStaff + ? "Choose another Chief of Staff first" + : visibleBotCount <= 1 + ? "Keep at least one active bot" + : undefined; // keep the menu on-screen near the click const top = Math.max(8, Math.min(menu.y, window.innerHeight - 380)); const left = Math.min(menu.x, window.innerWidth - 240); @@ -441,12 +457,12 @@ function BotContextMenu({ menu, onClose }: { menu: MenuState; onClose: () => voi }), divider("d3"), item( - , - "Hide from sidebar", - () => dispatch({ type: "updateBot", botId: bot.id, patch: { hidden: true } }), + , + "Archive", + () => onArchive(bot), { - disabled: Boolean(bot.chiefOfStaff), - hint: bot.chiefOfStaff ? "Choose another Chief of Staff first" : undefined, + disabled: archiveBlocked, + hint: archiveHint, }, ), item(, "Delete", () => dispatch({ type: "deleteBot", botId: bot.id }), { @@ -457,7 +473,17 @@ function BotContextMenu({ menu, onClose }: { menu: MenuState; onClose: () => voi ); } -function BotListItem({ bot, onMenu }: { bot: Bot; onMenu: (menu: MenuState) => void }) { +function BotListItem({ + bot, + onMenu, + onArchive, + archiveDisabled, +}: { + bot: Bot; + onMenu: (menu: MenuState) => void; + onArchive: (bot: Bot) => void; + archiveDisabled: boolean; +}) { const { state, dispatch } = useStore(); const [renaming, setRenaming] = useState(false); const selected = state.activeView === "chat" && state.selectedId === bot.id; @@ -466,7 +492,7 @@ function BotListItem({ bot, onMenu }: { bot: Bot; onMenu: (menu: MenuState) => v const visible = visibleMessages(bot); const last = visible.at(-1); const rowClass = cn( - "flex w-full items-center gap-3 rounded-xl border px-3 py-2.5 text-left", + "flex w-full items-center gap-3 rounded-xl border px-3 py-2.5 pr-10 text-left", bot.chiefOfStaff ? selected ? "border-accent/40 bg-accent/15" @@ -497,7 +523,7 @@ function BotListItem({ bot, onMenu }: { bot: Bot; onMenu: (menu: MenuState) => v /> {selected && last && !renaming && ( - + {formatTime(last.at)} )} @@ -535,24 +561,177 @@ function BotListItem({ bot, onMenu }: { bot: Bot; onMenu: (menu: MenuState) => v } return ( -
dispatch({ type: "select", id: bot.id })} - onKeyDown={(event) => { - if (event.key === "Enter" || event.key === " ") { - event.preventDefault(); - dispatch({ type: "select", id: bot.id }); +
+
dispatch({ type: "select", id: bot.id })} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + dispatch({ type: "select", id: bot.id }); + } + }} + onContextMenu={onContextMenu} + className={rowClass} + > + {body} +
+
); } +function ArchivedBotsPanel({ + bots, + onClose, + onRestored, +}: { + bots: Bot[]; + onClose: () => void; + onRestored: (message: string) => void; +}) { + const { dispatch } = useStore(); + const dialogRef = useRef(null); + const [busyId, setBusyId] = useState(null); + const [restoringAll, setRestoringAll] = useState(false); + const [error, setError] = useState(""); + + useEffect(() => { + dialogRef.current?.focus(); + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape" && !busyId && !restoringAll) onClose(); + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [busyId, onClose, restoringAll]); + + const restore = async (bot: Bot) => { + setBusyId(bot.id); + setError(""); + try { + const response = await api(`/api/bots/${bot.id}`, { + method: "PATCH", + body: JSON.stringify({ hidden: false }), + }); + dispatch({ type: "botPatched", bot: response.bot }); + dispatch({ type: "select", id: bot.id }); + onRestored(`${bot.name} restored`); + if (bots.length === 1) onClose(); + } catch (cause) { + setError(cause instanceof Error ? cause.message : String(cause)); + } finally { + setBusyId(null); + } + }; + + const restoreAll = async () => { + setRestoringAll(true); + setError(""); + try { + const responses = await Promise.all( + bots.map((bot) => + api(`/api/bots/${bot.id}`, { + method: "PATCH", + body: JSON.stringify({ hidden: false }), + }), + ), + ); + for (const response of responses) dispatch({ type: "botPatched", bot: response.bot }); + const first = bots[0]; + if (first) dispatch({ type: "select", id: first.id }); + onRestored(`${bots.length} ${bots.length === 1 ? "bot" : "bots"} restored`); + onClose(); + } catch (cause) { + setError(cause instanceof Error ? cause.message : String(cause)); + } finally { + setRestoringAll(false); + } + }; + + return createPortal( +
event.target === event.currentTarget && !busyId && !restoringAll && onClose()} + > +
+
+
+

Archived bots

+

Conversations are kept until you choose to delete a bot.

+
+
+ {bots.length > 1 && ( + + )} + +
+
+
+
{bots.length} archived
+
+ {bots.map((bot) => ( +
+ +
+
{bot.name}
+
{bot.title || "Bot"}
+
+ +
+ ))} +
+ {error &&
{error}
} +
+
+
, + document.body, + ); +} + export function Sidebar({ open, onClose }: { open: boolean; onClose: () => void }) { const { state, dispatch } = useStore(); const { capabilities } = useDesktopCapabilities(); @@ -562,8 +741,14 @@ export function Sidebar({ open, onClose }: { open: boolean; onClose: () => void const [plusOpen, setPlusOpen] = useState(false); const [newRoom, setNewRoom] = useState(false); const [teamLibraryOpen, setTeamLibraryOpen] = useState(false); + const [archivedBotsOpen, setArchivedBotsOpen] = useState(false); const [exportingTeam, setExportingTeam] = useState(false); - const [teamFeedback, setTeamFeedback] = useState<{ error: boolean; text: string } | null>(null); + const [teamFeedback, setTeamFeedback] = useState<{ + error: boolean; + text: string; + undo?: TeamImportResult; + restoreBot?: { id: string; name: string }; + } | null>(null); const [query, setQuery] = useState(""); // Esc closes the drawer, mirroring ApiKeys.tsx:75-85. Bound only while the @@ -602,6 +787,85 @@ export function Sidebar({ open, onClose }: { open: boolean; onClose: () => void } }; + const undoTeamLoad = async (result: TeamImportResult) => { + setTeamFeedback(null); + try { + const archiveNew = await Promise.all( + result.importedBotIds.map((botId) => + api(`/api/bots/${botId}`, { + method: "PATCH", + body: JSON.stringify({ hidden: true, chiefOfStaff: false }), + }), + ), + ); + for (const response of archiveNew) dispatch({ type: "botPatched", bot: response.bot }); + + const previousChief = result.archived.find((bot) => bot.chiefOfStaff); + const restoreOthers = await Promise.all( + result.archived + .filter((bot) => !bot.chiefOfStaff) + .map((bot) => + api(`/api/bots/${bot.id}`, { + method: "PATCH", + body: JSON.stringify({ hidden: false }), + }), + ), + ); + for (const response of restoreOthers) dispatch({ type: "botPatched", bot: response.bot }); + if (previousChief) { + const response = await api(`/api/bots/${previousChief.id}`, { + method: "PATCH", + body: JSON.stringify({ hidden: false, chiefOfStaff: true }), + }); + dispatch({ type: "botPatched", bot: response.bot }); + } + const first = result.archived[0]; + if (first) dispatch({ type: "select", id: first.id }); + setTeamFeedback({ error: false, text: "Previous team restored" }); + } catch (cause) { + setTeamFeedback({ error: true, text: cause instanceof Error ? cause.message : String(cause) }); + } + }; + + const archiveBot = async (bot: Bot) => { + const activeBots = state.bots.filter((candidate) => !candidate.hidden); + if (bot.chiefOfStaff || activeBots.length <= 1) return; + setTeamFeedback(null); + try { + const response = await api(`/api/bots/${bot.id}`, { + method: "PATCH", + body: JSON.stringify({ hidden: true }), + }); + dispatch({ type: "botPatched", bot: response.bot }); + if (state.selectedId === bot.id) { + const next = activeBots.find((candidate) => candidate.id !== bot.id); + if (next) dispatch({ type: "select", id: next.id }); + } + setTeamFeedback({ + error: false, + text: `${bot.name} archived`, + restoreBot: { id: bot.id, name: bot.name }, + }); + } catch (cause) { + setTeamFeedback({ error: true, text: cause instanceof Error ? cause.message : String(cause) }); + } + }; + + const undoBotArchive = async (bot: { id: string; name: string }) => { + setTeamFeedback(null); + try { + const response = await api(`/api/bots/${bot.id}`, { + method: "PATCH", + body: JSON.stringify({ hidden: false }), + }); + dispatch({ type: "botPatched", bot: response.bot }); + dispatch({ type: "select", id: bot.id }); + setTeamFeedback({ error: false, text: `${bot.name} restored` }); + } catch (cause) { + setTeamFeedback({ error: true, text: cause instanceof Error ? cause.message : String(cause) }); + } + }; + const macInset = capabilities.windowChrome === "mac-inset"; const browser = capabilities.host.label === "Browser"; @@ -620,6 +884,10 @@ export function Sidebar({ open, onClose }: { open: boolean; onClose: () => void .filter((bot) => !bot.chiefOfStaff) .sort((a, b) => Number(b.pinned ?? false) - Number(a.pinned ?? false)); const visibleGroups = state.groups.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; return (
)} @@ -738,14 +1019,25 @@ export function Sidebar({ open, onClose }: { open: boolean; onClose: () => void )} {chiefBot && (
- + void archiveBot(bot)} + archiveDisabled + />
)} {visibleGroups.map((g) => ( ))} {visibleBots.map((b) => ( - + void archiveBot(bot)} + archiveDisabled={activeBotCount <= 1} + /> ))}
@@ -793,7 +1085,7 @@ export function Sidebar({ open, onClose }: { open: boolean; onClose: () => void
- {menu && setMenu(null)} />} + {menu && setMenu(null)} onArchive={(bot) => void archiveBot(bot)} />} {roomMenu && ( void /> )} {newRoom && setNewRoom(false)} />} + {archivedBotsOpen && ( + setArchivedBotsOpen(false)} + onRestored={(message) => setTeamFeedback({ error: false, text: message })} + /> + )} {teamLibraryOpen && ( setTeamLibraryOpen(false)} - onImported={(name, members) => { + onImported={(result) => { setTeamLibraryOpen(false); - setTeamFeedback({ error: false, text: `${name} imported · ${members} ${members === 1 ? "bot" : "bots"}` }); + setTeamFeedback( + result.archived.length > 0 + ? { + error: false, + text: `${result.name} loaded · ${result.members} ${result.members === 1 ? "bot" : "bots"}`, + undo: result, + } + : { + error: false, + text: `${result.name} loaded · ${result.members} ${result.members === 1 ? "bot" : "bots"}`, + }, + ); }} /> )} @@ -822,7 +1132,25 @@ export function Sidebar({ open, onClose }: { open: boolean; onClose: () => void : "border-hairline/50 bg-card text-ink", )} > - {teamFeedback.text} +
+ {teamFeedback.text} + {pendingTeamUndo && ( + + )} + {pendingBotUndo && ( + + )} +
, document.body, )} diff --git a/src/components/TeamLibraryPanel.tsx b/src/components/TeamLibraryPanel.tsx index 282f95d53..ff2782022 100644 --- a/src/components/TeamLibraryPanel.tsx +++ b/src/components/TeamLibraryPanel.tsx @@ -1,14 +1,14 @@ import { track } from "@/lib/analytics"; +import { cn } from "@/lib/cn"; import { teamImportPreview, type PendingTeamImport } from "@/lib/team-import"; import { api, useStore, type Bot } from "@/state/store"; import { ArrowLeft, + Check, ExternalLink, - FileJson, Github, - Library, - Link as LinkIcon, Loader2, + Search, UploadCloud, Users, X, @@ -36,8 +36,28 @@ interface TeamCatalog { teams: TeamCatalogEntry[]; } +export interface ArchivedTeamBot { + id: string; + chiefOfStaff: boolean; +} + +export interface TeamImportResult { + name: string; + members: number; + importedBotIds: string[]; + archived: ArchivedTeamBot[]; +} + type ImportSource = "library" | "file" | "github"; -type ImportTab = "explore" | "file" | "github"; +type TeamTab = "explore" | "import"; +type ImportMode = "replace" | "add"; + +const TEAM_GLYPHS = [ + "bg-purple-500/15 text-purple-300", + "bg-cyan-500/15 text-cyan-300", + "bg-orange-500/15 text-orange-300", + "bg-emerald-500/15 text-emerald-300", +] as const; async function openExternal(url: string): Promise { if (window.ogb?.openExternal) { @@ -48,9 +68,12 @@ async function openExternal(url: string): Promise { if (opened) opened.opener = null; } -function teamSourceUrl(repositoryUrl: string, entry: TeamCatalogEntry): string { - const folder = entry.readme.replace(/\/README\.md$/, ""); - return `${repositoryUrl}/tree/main/${folder}`; +function TeamGlyph({ index }: { index: number }) { + return ( +
+ +
+ ); } export function TeamLibraryPanel({ @@ -59,13 +82,13 @@ export function TeamLibraryPanel({ returnFocusRef, }: { onClose: () => void; - onImported: (name: string, members: number) => void; + onImported: (result: TeamImportResult) => void; returnFocusRef: React.RefObject; }) { - const { dispatch } = useStore(); + const { state, dispatch } = useStore(); const dialogRef = useRef(null); const fileInputRef = useRef(null); - const [tab, setTab] = useState("explore"); + const [tab, setTab] = useState("explore"); const [catalog, setCatalog] = useState(null); const [catalogLoading, setCatalogLoading] = useState(true); const [catalogError, setCatalogError] = useState(""); @@ -76,14 +99,18 @@ export function TeamLibraryPanel({ const [githubLoading, setGithubLoading] = useState(false); const [importing, setImporting] = useState(false); const [dragging, setDragging] = useState(false); + const [importMode, setImportMode] = useState("replace"); + const [search, setSearch] = useState(""); const [error, setError] = useState(""); + const currentBotCount = state.bots.filter((bot) => !bot.hidden).length; + const loadCatalog = useCallback(async () => { setCatalogLoading(true); setCatalogError(""); try { - const result = (await api("/api/team-library/catalog")) as TeamCatalog; - setCatalog(result); + // SAFETY: this endpoint is owned by the app and returns TeamCatalog. + setCatalog((await api("/api/team-library/catalog")) as TeamCatalog); } catch (cause) { setCatalogError(cause instanceof Error ? cause.message : String(cause)); } finally { @@ -131,9 +158,10 @@ export function TeamLibraryPanel({ return () => window.removeEventListener("keydown", onKeyDown, true); }, [importing, onClose, pending]); - const previewManifest = (manifest: unknown, nextSource: ImportSource) => { - setPending(teamImportPreview(manifest)); + const previewManifest = (preview: PendingTeamImport, nextSource: ImportSource) => { + setPending(preview); setSource(nextSource); + setImportMode(currentBotCount > 0 ? "replace" : "add"); setError(""); }; @@ -146,14 +174,14 @@ export function TeamLibraryPanel({ if (cause instanceof SyntaxError) throw new Error("That team file is not valid JSON."); throw cause; } - previewManifest(manifest, "file"); + previewManifest(teamImportPreview(manifest), "file"); }; const loadLibraryTeam = async (entry: TeamCatalogEntry) => { setBusySlug(entry.slug); setError(""); try { - previewManifest(await api(`/api/team-library/teams/${entry.slug}`), "library"); + previewManifest(teamImportPreview(await api(`/api/team-library/teams/${entry.slug}`)), "library"); } catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)); } finally { @@ -170,7 +198,7 @@ export function TeamLibraryPanel({ method: "POST", body: JSON.stringify({ url: githubUrl.trim() }), }); - previewManifest(manifest, "github"); + previewManifest(teamImportPreview(manifest), "github"); } catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)); } finally { @@ -183,15 +211,22 @@ export function TeamLibraryPanel({ setImporting(true); setError(""); try { - const response = (await api("/api/teams/import", { + // SAFETY: this endpoint is owned by the app and returns imported bots. + const response = (await api(`/api/teams/import?mode=${importMode}`, { method: "POST", body: JSON.stringify(pending.manifest), - })) as { bots: Bot[] }; + })) as { bots: Bot[]; archivedBots?: Bot[]; archived?: ArchivedTeamBot[] }; + for (const bot of response.archivedBots ?? []) dispatch({ type: "botPatched", bot }); for (const bot of response.bots) dispatch({ type: "botAdded", bot }); const first = response.bots[0]; if (first) dispatch({ type: "select", id: first.id }); - track("team_imported", { members: response.bots.length, source }); - onImported(pending.name, response.bots.length); + track("team_imported", { members: response.bots.length, source, mode: importMode }); + onImported({ + name: pending.name, + members: response.bots.length, + importedBotIds: response.bots.map((bot) => bot.id), + archived: response.archived ?? [], + }); } catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)); } finally { @@ -199,9 +234,17 @@ export function TeamLibraryPanel({ } }; + const normalizedSearch = search.trim().toLowerCase(); + const visibleTeams = (catalog?.teams ?? []).filter((entry) => { + if (!normalizedSearch) return true; + return `${entry.name} ${entry.summary} ${entry.category} ${entry.skills.join(" ")} ${entry.requires.apps.join(" ")}` + .toLowerCase() + .includes(normalizedSearch); + }); + return createPortal(
event.target === event.currentTarget && !importing && onClose()} >
-
+
{pending && ( @@ -222,151 +265,203 @@ export function TeamLibraryPanel({ setError(""); }} disabled={importing} - className="rounded-md p-1 text-ink-secondary hover:bg-raised hover:text-ink disabled:opacity-50" - aria-label="Back to team library" + className="rounded-lg p-1.5 text-ink-secondary hover:bg-raised hover:text-ink disabled:opacity-50" + aria-label="Back to teams" > - + )} -

- {pending ? `Import ${pending.name}?` : "Add a team"} +

+ {pending ? pending.name : "Teams"}

-

- {pending - ? `This creates ${pending.members.length} new ${pending.members.length === 1 ? "bot" : "bots"}. No room is created.` - : "Start with a community team, a local file, or a public GitHub link."} +

+ {pending ? `${pending.members.length} ready-to-load bots` : "Start with a complete team or bring your own."}

- + {!pending && ( + + )}
{pending ? ( -
- {pending.description &&

{pending.description}

} -
- {pending.members.map((member, index) => ( -
- {member.name} - - {member.title || "General assistant"} - -
- ))} + <> +
+ {pending.description && ( +

{pending.description}

+ )} +
Team members
+
+ {pending.members.map((member, index) => ( +
+
+ {member.name.slice(0, 1).toUpperCase()} +
+
+
{member.name}
+
{member.title || "General assistant"}
+
+
+ ))} +
+
+ +

+ Only roles and appearance are loaded. Your conversations, account connections, permissions, and computer access stay private. + {source === "library" && " Playbooks remain available in the community repo for review."} +

+
+ {error &&
{error}
}
-

- Bots use your default engine. Conversations, API keys, permissions, provider sessions, and computer access are never imported. -

- {source === "library" && ( -

- The team's role playbooks are available in the community repo for you to review; this import currently adds the bot roles only. -

- )} - {error &&
{error}
} -
+ +
+
+ {currentBotCount > 0 ? ( + importMode === "replace" ? ( + <> + Replaces your {currentBotCount} current {currentBotCount === 1 ? "bot" : "bots"}. They'll be archived with conversations intact.{" "} + + + ) : ( + <> + This team will be added alongside your current bots.{" "} + + + ) + ) : ( + "No room is created—you can make one later if you want." + )} +
+ +
+ ) : ( <> -
- {([ - ["explore", Library, "Explore"], - ["file", FileJson, "From file"], - ["github", Github, "From GitHub"], - ] as const).map(([value, Icon, label]) => ( +
+
- ))} + +
+ {tab === "explore" && ( + + )}
-
+
{tab === "explore" && (
+
+ {search ? "Search results" : "Community teams"} +
{catalogLoading && ( -
- Loading community teams… +
+ Loading teams…
)} {!catalogLoading && catalogError && ( -
+

{catalogError}

- +
)} {!catalogLoading && catalog && ( -
- {catalog.teams.map((entry) => ( -
-
-
- {entry.category} -

{entry.name}

-
- -
-

{entry.summary}

-
- {entry.members} bots - {entry.skills.length} playbooks +
+ {visibleTeams.map((entry, index) => ( +
+ +
+

{entry.name}

+

{entry.summary}

+

{entry.members} bots · {entry.skills.length} playbooks

- {entry.requires.apps.length > 0 && ( -

- Works with {entry.requires.apps.join(", ")} -

- )}
))}
)} + {!catalogLoading && catalog && visibleTeams.length === 0 && ( +
+
No teams found
+
Try a different search.
+
+ )}
)} - {tab === "file" && ( + {tab === "import" && (
setError(cause instanceof Error ? cause.message : String(cause))); }} /> - -
- )} - - {tab === "github" && ( -
-
-

Load a public GitHub team

-

- Paste a repository containing `team.mausteam.json`, or a direct GitHub link to any OpenMaus team JSON file. -

-
- setGithubUrl(event.target.value)} - onKeyDown={(event) => event.key === "Enter" && void loadGithubTeam()} - placeholder="https://github.com/owner/team-repo" - aria-label="GitHub team URL" - className="min-w-0 flex-1 rounded-lg bg-raised/70 px-3 py-2.5 text-[13.5px] text-ink placeholder:text-ink-secondary focus:outline-none" - /> +
Bring your own team
+
+ +
+ +

Load from GitHub

+

Paste a public repo or a direct team JSON link.

+
+ setGithubUrl(event.target.value)} + onKeyDown={(event) => event.key === "Enter" && void loadGithubTeam()} + placeholder="github.com/owner/repo" + aria-label="GitHub team URL" + className="min-w-0 flex-1 rounded-xl bg-raised/80 px-3 py-2.5 text-[13px] text-ink placeholder:text-ink-secondary focus:outline-none" + /> + +
+
-

Only public HTTPS links from github.com are fetched.

+ {error &&
{error}
}
)} - - {error &&
{error}
}
)} - - {pending && ( - - )}
, document.body,