diff --git a/mcpjam-inspector/client/src/components/chatboxes/builder/ChatboxBuilderExperience.tsx b/mcpjam-inspector/client/src/components/chatboxes/builder/ChatboxBuilderExperience.tsx index 67a753794..cc302386d 100644 --- a/mcpjam-inspector/client/src/components/chatboxes/builder/ChatboxBuilderExperience.tsx +++ b/mcpjam-inspector/client/src/components/chatboxes/builder/ChatboxBuilderExperience.tsx @@ -19,7 +19,10 @@ import { } from "@/hooks/useWorkspaces"; import { readBuilderSession, clearBuilderSession } from "@/lib/chatbox-session"; import { ChatboxIndexPage, type ChatboxOpenOptions } from "./ChatboxIndexPage"; -import { ChatboxBuilderView } from "./ChatboxBuilderView"; +import { + ChatboxBuilderView, + type SavedDraftNavigationOptions, +} from "./ChatboxBuilderView"; import { ChatboxLauncher } from "./ChatboxLauncher"; import { getDefaultHostedModelId } from "./drafts"; import type { ChatboxDraftConfig, ChatboxStarterDefinition } from "./types"; @@ -65,6 +68,8 @@ export default function ChatboxBuilderExperience({ const [restoredViewMode, setRestoredViewMode] = useState< "setup" | "preview" | "usage" | "insights" | undefined >(); + const [restoredFocusedSetupSection, setRestoredFocusedSetupSection] = + useState(); const [starterLauncherOpen, setStarterLauncherOpen] = useState(false); const [deletingChatboxId, setDeletingChatboxId] = useState( null, @@ -94,6 +99,7 @@ export default function ChatboxBuilderExperience({ startTransition(() => { setSelectedChatboxId(session.chatboxId); setDraft((session.draft as ChatboxDraftConfig | null) ?? null); + setRestoredFocusedSetupSection(undefined); const vm = session.viewMode; if (vm === "builder") { setRestoredViewMode("setup"); @@ -116,6 +122,7 @@ export default function ChatboxBuilderExperience({ setSelectedChatboxId(null); setDraft(starter.createDraft(getDefaultHostedModelId())); setRestoredViewMode(undefined); + setRestoredFocusedSetupSection(undefined); setStarterLauncherOpen(false); }); }, @@ -133,13 +140,19 @@ export default function ChatboxBuilderExperience({ [applyStarterDraft], ); - const handleSavedDraft = useCallback((chatbox: ChatboxSettings) => { - startTransition(() => { - setDraft(null); - setSelectedChatboxId(chatbox.chatboxId); - setRestoredViewMode(undefined); - }); - }, []); + const handleSavedDraft = useCallback( + (chatbox: ChatboxSettings, options?: SavedDraftNavigationOptions) => { + startTransition(() => { + setDraft(null); + setSelectedChatboxId(chatbox.chatboxId); + setRestoredViewMode(options?.initialViewMode); + setRestoredFocusedSetupSection( + options?.initialFocusedSetupSection, + ); + }); + }, + [], + ); const handleDeleteChatbox = useCallback( async (chatbox: ChatboxListItem) => { @@ -177,6 +190,7 @@ export default function ChatboxBuilderExperience({ startTransition(() => { setSelectedChatboxId(newId); setRestoredViewMode(undefined); + setRestoredFocusedSetupSection(undefined); }); } } catch (error) { @@ -220,6 +234,7 @@ export default function ChatboxBuilderExperience({ chatboxId={selectedChatboxId} draft={draft} initialViewMode={restoredViewMode} + initialFocusedSetupSection={restoredFocusedSetupSection} onSavedDraft={handleSavedDraft} onBack={() => { clearBuilderSession(); @@ -227,6 +242,7 @@ export default function ChatboxBuilderExperience({ setSelectedChatboxId(null); setDraft(null); setRestoredViewMode(undefined); + setRestoredFocusedSetupSection(undefined); }); }} /> @@ -238,6 +254,7 @@ export default function ChatboxBuilderExperience({ startTransition(() => { setSelectedChatboxId(chatboxId); setRestoredViewMode(options?.initialViewMode); + setRestoredFocusedSetupSection(undefined); }); }} onDuplicateChatbox={handleDuplicateChatbox} diff --git a/mcpjam-inspector/client/src/components/chatboxes/builder/ChatboxBuilderView.tsx b/mcpjam-inspector/client/src/components/chatboxes/builder/ChatboxBuilderView.tsx index 537627a20..a65a6d942 100644 --- a/mcpjam-inspector/client/src/components/chatboxes/builder/ChatboxBuilderView.tsx +++ b/mcpjam-inspector/client/src/components/chatboxes/builder/ChatboxBuilderView.tsx @@ -90,8 +90,12 @@ interface ChatboxBuilderViewProps { chatboxId?: string | null; draft: ChatboxDraftConfig | null; initialViewMode?: "setup" | "preview" | "usage" | "insights"; + initialFocusedSetupSection?: SetupSectionId | null; onBack: () => void; - onSavedDraft: (chatbox: ChatboxSettings) => void; + onSavedDraft: ( + chatbox: ChatboxSettings, + options?: SavedDraftNavigationOptions + ) => void; } /** Right (setup) rail: favor setup on desktop */ @@ -100,9 +104,15 @@ const DESKTOP_SETUP_RAIL_MIN_PERCENT = 40; const DESKTOP_SETUP_RAIL_MAX_PERCENT = 70; type ViewMode = "setup" | "preview" | "usage" | "insights"; +type SavedDraftViewMode = Extract; + +export interface SavedDraftNavigationOptions { + initialViewMode?: SavedDraftViewMode; + initialFocusedSetupSection?: SetupSectionId | null; +} function normalizeInitialViewMode( - mode: string | undefined, + mode: string | undefined ): ViewMode | undefined { if (!mode) return undefined; if ( @@ -139,7 +149,9 @@ function ChatboxPreviewActionButtons({ }) { const showCopyLink = hasSavedChatbox; const isSidebar = variant === "sidebar"; - const buttonClass = isSidebar ? "w-full justify-start rounded-xl" : "rounded-xl"; + const buttonClass = isSidebar + ? "w-full justify-start rounded-xl" + : "rounded-xl"; return (
{showCopyLink ? ( - @@ -287,6 +295,7 @@ export function ChatboxBuilderView({ chatboxId, draft, initialViewMode, + initialFocusedSetupSection, onBack, onSavedDraft, }: ChatboxBuilderViewProps) { @@ -326,7 +335,7 @@ export function ChatboxBuilderView({ everyNToolCalls: 1, promptHint: "", }, - } as ChatboxSettings), + } as ChatboxSettings) ); return { ...base, @@ -334,19 +343,22 @@ export function ChatboxBuilderView({ }; }); const [viewMode, setViewMode] = useState( - () => normalizeInitialViewMode(initialViewMode) ?? "setup", + () => normalizeInitialViewMode(initialViewMode) ?? "setup" ); const [chatKey, setChatKey] = useState(0); const [playgroundId, setPlaygroundId] = useState(() => crypto.randomUUID()); const [isSetupSheetOpen, setIsSetupSheetOpen] = useState(true); const [selectedNodeId, setSelectedNodeId] = useState("host"); const [focusedSetupSection, setFocusedSetupSection] = - useState(null); + useState(initialFocusedSetupSection ?? null); const [desktopSettingsPaneSize, setDesktopSettingsPaneSize] = useState( - DESKTOP_SETUP_RAIL_DEFAULT_PERCENT, + DESKTOP_SETUP_RAIL_DEFAULT_PERCENT ); const [isSaving, setIsSaving] = useState(false); const [isAddServerOpen, setIsAddServerOpen] = useState(false); + const [stagedAccessInviteEmails, setStagedAccessInviteEmails] = useState< + string[] + >([]); const [canvasViewportRefitNonce, setCanvasViewportRefitNonce] = useState(0); const panelGroupContainerRef = useRef(null); const rightPanelRef = useRef(null); @@ -366,6 +378,25 @@ export function ChatboxBuilderView({ }); }, [workspaceId, chatboxId, draftChatboxConfig, viewMode]); + useEffect(() => { + const nextViewMode = normalizeInitialViewMode(initialViewMode); + if (!nextViewMode) return; + setViewMode(nextViewMode); + }, [initialViewMode]); + + useEffect(() => { + if (initialFocusedSetupSection === undefined) return; + setFocusedSetupSection(initialFocusedSetupSection ?? null); + if (initialFocusedSetupSection) { + setIsSetupSheetOpen(true); + } + }, [initialFocusedSetupSection]); + + useEffect(() => { + if (draftChatboxConfig.mode === "invited_only") return; + setStagedAccessInviteEmails((current) => (current.length ? [] : current)); + }, [draftChatboxConfig.mode]); + const behaviorFingerprint = useMemo( () => JSON.stringify({ @@ -382,13 +413,13 @@ export function ChatboxBuilderView({ selectedServerIds: [...draftChatboxConfig.selectedServerIds].sort(), optionalServerIds: [...draftChatboxConfig.optionalServerIds].sort(), }), - [draftChatboxConfig], + [draftChatboxConfig] ); const setupHasBlockingSections = useMemo(() => { const statuses = computeSectionStatuses( draftChatboxConfig, - workspaceServers, + workspaceServers ); return Object.values(statuses).some((kind) => kind === "attention"); }, [draftChatboxConfig, workspaceServers]); @@ -515,7 +546,7 @@ export function ChatboxBuilderView({ draft: draftChatboxConfig, workspaceServers, }), - [draftChatboxConfig, chatbox, workspaceServers], + [draftChatboxConfig, chatbox, workspaceServers] ); const viewModel = useMemo(() => buildChatboxCanvas(context), [context]); const desktopRightPanelDefaultSize = desktopSettingsPaneSize; @@ -550,7 +581,7 @@ export function ChatboxBuilderView({ enabled: chatbox.feedbackDialog?.enabled ?? true, everyNToolCalls: Math.max( 1, - chatbox.feedbackDialog?.everyNToolCalls ?? 1, + chatbox.feedbackDialog?.everyNToolCalls ?? 1 ), promptHint: chatbox.feedbackDialog?.promptHint ?? "", }) || @@ -579,7 +610,7 @@ export function ChatboxBuilderView({ if (!introChatboxId) return; try { const raw = sessionStorage.getItem( - chatboxPreviewEnabledOptionalStorageKey(introChatboxId), + chatboxPreviewEnabledOptionalStorageKey(introChatboxId) ); if (!raw) { setPreviewEnabledOptionalIds((prev) => (prev.length === 0 ? prev : [])); @@ -589,7 +620,7 @@ export function ChatboxBuilderView({ if (!Array.isArray(parsed)) return; const optionalSet = new Set(draftChatboxConfig.optionalServerIds); const next = parsed.filter( - (id): id is string => typeof id === "string" && optionalSet.has(id), + (id): id is string => typeof id === "string" && optionalSet.has(id) ); setPreviewEnabledOptionalIds((prev) => { if ( @@ -642,13 +673,13 @@ export function ChatboxBuilderView({ const requiredPreviewServers = useMemo( () => selectedPreviewServers.filter((s) => !s.optional), - [selectedPreviewServers], + [selectedPreviewServers] ); const activePreviewServers = useMemo(() => { const enabled = new Set(previewEnabledOptionalIds); const optionalActive = selectedPreviewServers.filter( - (s) => s.optional && enabled.has(s.serverId), + (s) => s.optional && enabled.has(s.serverId) ); return [...requiredPreviewServers, ...optionalActive]; }, [ @@ -680,7 +711,7 @@ export function ChatboxBuilderView({ const previewOAuthGateServers = useMemo( () => activePreviewServers.map(bootstrapServerToHostedOAuthDescriptor), - [activePreviewServers], + [activePreviewServers] ); const { @@ -702,7 +733,7 @@ export function ChatboxBuilderView({ return token ? ([server.serverId, token] as const) : null; }) .filter((entry): entry is readonly [string, string] => - Array.isArray(entry), + Array.isArray(entry) ); return entries.length > 0 ? Object.fromEntries(entries) : undefined; }, [oauthStateByServerId, activePreviewServers]); @@ -728,100 +759,162 @@ export function ChatboxBuilderView({ (details?: HostedOAuthRequiredDetails) => { markOAuthRequired(details); }, - [markOAuthRequired], + [markOAuthRequired] ); - const saveChatbox = useCallback(async (): Promise => { - const trimmedName = draftChatboxConfig.name.trim(); - if (!trimmedName) { - toast.error("Chatbox name is required"); - return false; - } - if (draftChatboxConfig.selectedServerIds.length === 0) { - toast.error("Select at least one HTTPS server"); - return false; - } - if ( - countRequiredServers( - draftChatboxConfig.selectedServerIds, - draftChatboxConfig.optionalServerIds, - ) < 1 - ) { - toast.error("At least one server must be required (on by default)"); - return false; - } - const selectedServers = workspaceServers.filter((server) => - draftChatboxConfig.selectedServerIds.includes(server._id), - ); - if (selectedServers.some((server) => isInsecureUrl(server.url))) { - toast.error("Only HTTPS servers can be used in chatboxes"); - return false; - } + const saveChatbox = useCallback( + async ({ + targetViewMode, + }: { + targetViewMode?: SavedDraftViewMode; + } = {}): Promise => { + const requestedViewMode = + targetViewMode ?? (viewMode === "preview" ? "preview" : "setup"); + const trimmedName = draftChatboxConfig.name.trim(); + if (!trimmedName) { + toast.error("Chatbox name is required"); + return false; + } + if (draftChatboxConfig.selectedServerIds.length === 0) { + toast.error("Select at least one HTTPS server"); + return false; + } + if ( + countRequiredServers( + draftChatboxConfig.selectedServerIds, + draftChatboxConfig.optionalServerIds + ) < 1 + ) { + toast.error("At least one server must be required (on by default)"); + return false; + } + const selectedServers = workspaceServers.filter((server) => + draftChatboxConfig.selectedServerIds.includes(server._id) + ); + if (selectedServers.some((server) => isInsecureUrl(server.url))) { + toast.error("Only HTTPS servers can be used in chatboxes"); + return false; + } - setIsSaving(true); - try { - const payload = { - name: trimmedName, - description: draftChatboxConfig.description.trim() || undefined, - hostStyle: draftChatboxConfig.hostStyle, - systemPrompt: - draftChatboxConfig.systemPrompt.trim() || DEFAULT_SYSTEM_PROMPT, - modelId: draftChatboxConfig.modelId, - temperature: draftChatboxConfig.temperature, - requireToolApproval: draftChatboxConfig.requireToolApproval, - serverIds: draftChatboxConfig.selectedServerIds, - optionalServerIds: draftChatboxConfig.optionalServerIds, - allowGuestAccess: draftChatboxConfig.allowGuestAccess, - welcomeDialog: draftChatboxConfig.welcomeDialog, - feedbackDialog: draftChatboxConfig.feedbackDialog, - }; + setIsSaving(true); + try { + const payload = { + name: trimmedName, + description: draftChatboxConfig.description.trim() || undefined, + hostStyle: draftChatboxConfig.hostStyle, + systemPrompt: + draftChatboxConfig.systemPrompt.trim() || DEFAULT_SYSTEM_PROMPT, + modelId: draftChatboxConfig.modelId, + temperature: draftChatboxConfig.temperature, + requireToolApproval: draftChatboxConfig.requireToolApproval, + serverIds: draftChatboxConfig.selectedServerIds, + optionalServerIds: draftChatboxConfig.optionalServerIds, + allowGuestAccess: draftChatboxConfig.allowGuestAccess, + welcomeDialog: draftChatboxConfig.welcomeDialog, + feedbackDialog: draftChatboxConfig.feedbackDialog, + }; - if (!chatbox) { - let created = (await createChatbox({ - workspaceId, - ...payload, - })) as ChatboxSettings; - if (draftChatboxConfig.mode !== "invited_only") { - created = (await setChatboxMode({ - chatboxId: created.chatboxId, - mode: draftChatboxConfig.mode, + if (!chatbox) { + let created = (await createChatbox({ + workspaceId, + ...payload, })) as ChatboxSettings; + if (draftChatboxConfig.mode !== "invited_only") { + created = (await setChatboxMode({ + chatboxId: created.chatboxId, + mode: draftChatboxConfig.mode, + })) as ChatboxSettings; + } + + const emailsToInvite = + draftChatboxConfig.mode === "invited_only" + ? stagedAccessInviteEmails + : []; + + toast.success("Chatbox created"); + + let hadInviteFailure = false; + for (const email of emailsToInvite) { + try { + await upsertChatboxMember({ + chatboxId: created.chatboxId, + email, + sendInviteEmail: true, + }); + } catch (error) { + hadInviteFailure = true; + toast.error( + error instanceof Error + ? error.message + : `Failed to invite ${email}` + ); + } + } + + setStagedAccessInviteEmails([]); + + const shouldReturnToAccess = + emailsToInvite.length > 0 && + (requestedViewMode === "setup" || hadInviteFailure); + const navigation: SavedDraftNavigationOptions = + shouldReturnToAccess + ? { + initialViewMode: "setup", + initialFocusedSetupSection: "access", + } + : { initialViewMode: requestedViewMode }; + + if (shouldReturnToAccess) { + setViewMode("setup"); + setFocusedSetupSection("access"); + setIsSetupSheetOpen(true); + } else { + setViewMode(navigation.initialViewMode ?? "setup"); + setFocusedSetupSection( + navigation.initialFocusedSetupSection ?? null + ); + if (navigation.initialViewMode === "setup") { + setIsSetupSheetOpen(true); + } + } + onSavedDraft(created, navigation); + return true; } - toast.success("Chatbox created"); - setViewMode("preview"); - onSavedDraft(created); + + await updateChatbox({ + chatboxId: chatbox.chatboxId, + ...payload, + }); + toast.success("Chatbox updated"); return true; + } catch (error) { + toast.error(getBillingErrorMessage(error, "Failed to save chatbox")); + return false; + } finally { + setIsSaving(false); } - - await updateChatbox({ - chatboxId: chatbox.chatboxId, - ...payload, - }); - toast.success("Chatbox updated"); - return true; - } catch (error) { - toast.error(getBillingErrorMessage(error, "Failed to save chatbox")); - return false; - } finally { - setIsSaving(false); - } - }, [ - createChatbox, - draftChatboxConfig, - onSavedDraft, - chatbox, - setChatboxMode, - updateChatbox, - workspaceId, - workspaceServers, - ]); + }, + [ + createChatbox, + draftChatboxConfig, + onSavedDraft, + chatbox, + setChatboxMode, + stagedAccessInviteEmails, + updateChatbox, + upsertChatboxMember, + viewMode, + workspaceId, + workspaceServers, + ] + ); const saveAndOpenPreview = useCallback(async () => { - const ok = await saveChatbox(); - if (ok) { + const ok = await saveChatbox({ targetViewMode: "preview" }); + if (ok && chatbox) { setViewMode("preview"); } - }, [saveChatbox]); + }, [chatbox, saveChatbox]); const handleCopyLink = useCallback(async () => { if (!shareLink) { @@ -844,7 +937,7 @@ export function ChatboxBuilderView({ const link = buildPlaygroundChatboxLink( chatbox.link.token, draftChatboxConfig.name || chatbox.name, - playgroundId, + playgroundId ); window.open(link, "_blank", "noopener,noreferrer"); }, [chatbox, draftChatboxConfig.name, playgroundId]); @@ -877,7 +970,7 @@ export function ChatboxBuilderView({ selectedServerIds: updateSelectedServerIds( current.selectedServerIds, serverId, - true, + true ), })); setSelectedNodeId(`server:${serverId}`); @@ -888,7 +981,7 @@ export function ChatboxBuilderView({ toast.error(getBillingErrorMessage(error, "Failed to add server")); } }, - [createServer, workspaceId], + [createServer, workspaceId] ); const handleToggleServer = useCallback( @@ -897,7 +990,7 @@ export function ChatboxBuilderView({ const selectedServerIds = updateSelectedServerIds( current.selectedServerIds, serverId, - checked, + checked ); if (selectedServerIds === current.selectedServerIds) { @@ -923,10 +1016,10 @@ export function ChatboxBuilderView({ } setSelectedNodeId((current) => - current === `server:${serverId}` ? "host" : current, + current === `server:${serverId}` ? "host" : current ); }, - [], + [] ); const previewRailConfig = useMemo(() => { @@ -938,7 +1031,7 @@ export function ChatboxBuilderView({ feedbackOn: chatbox.feedbackDialog?.enabled ?? true, feedbackEvery: Math.max( 1, - chatbox.feedbackDialog?.everyNToolCalls ?? 1, + chatbox.feedbackDialog?.everyNToolCalls ?? 1 ), }; } @@ -972,15 +1065,26 @@ export function ChatboxBuilderView({ setIsAddServerOpen(true); }, onToggleServer: handleToggleServer, - inviteChatboxMember: chatboxId - ? async (email: string) => { - await upsertChatboxMember({ - chatboxId, - email: email.trim().toLowerCase(), - sendInviteEmail: true, - }); - } - : undefined, + stagedAccessInviteEmails, + onStagedAccessInviteEmailAdd: (email: string) => { + const normalized = email.trim().toLowerCase(); + setStagedAccessInviteEmails((prev) => + prev.includes(normalized) ? prev : [...prev, normalized] + ); + }, + onStagedAccessInviteEmailRemove: (email: string) => { + setStagedAccessInviteEmails((prev) => prev.filter((e) => e !== email)); + }, + inviteChatboxMember: + chatbox?.chatboxId ?? chatboxId + ? async (email: string) => { + await upsertChatboxMember({ + chatboxId: (chatbox?.chatboxId ?? chatboxId)!, + email: email.trim().toLowerCase(), + sendInviteEmail: true, + }); + } + : undefined, }; const setupPanelDesktop = ( @@ -1099,7 +1203,7 @@ export function ChatboxBuilderView({ chatboxServerConfigs } selectedServerNames={Object.keys( - chatboxServerConfigs, + chatboxServerConfigs )} minimalMode reasoningDisplayMode="hidden" @@ -1107,7 +1211,7 @@ export function ChatboxBuilderView({ chatbox!.workspaceId } hostedSelectedServerIdsOverride={activePreviewServers.map( - (s) => s.serverId, + (s) => s.serverId )} hostedOAuthTokensOverride={previewOAuthTokens} hostedChatboxToken={chatbox.link.token} @@ -1123,7 +1227,7 @@ export function ChatboxBuilderView({ draftChatboxConfig.requireToolApproval } loadingIndicatorVariant={getLoadingIndicatorVariantForHostStyle( - draftChatboxConfig.hostStyle, + draftChatboxConfig.hostStyle )} onOAuthRequired={handlePreviewOAuthRequired} chatboxComposerBlocked={ @@ -1135,8 +1239,8 @@ export function ChatboxBuilderView({ (s) => s.optional && !previewEnabledOptionalIds.includes( - s.serverId, - ), + s.serverId + ) ) .map((s) => ({ serverId: s.serverId, @@ -1145,7 +1249,7 @@ export function ChatboxBuilderView({ }))} onEnableChatboxOptionalServer={(id) => { setPreviewEnabledOptionalIds((prev) => - prev.includes(id) ? prev : [...prev, id], + prev.includes(id) ? prev : [...prev, id] ); }} /> @@ -1153,7 +1257,9 @@ export function ChatboxBuilderView({
{getChatboxHostStyleShortLabel( - previewRailConfig.hostStyle, + previewRailConfig.hostStyle )}
@@ -1251,7 +1357,7 @@ export function ChatboxBuilderView({ onSelectNode={(nodeId) => { setSelectedNodeId(nodeId); setFocusedSetupSection( - getSetupSectionForNode(nodeId), + getSetupSectionForNode(nodeId) ); setIsSetupSheetOpen(true); }} diff --git a/mcpjam-inspector/client/src/components/chatboxes/builder/__tests__/ChatboxBuilderView.test.tsx b/mcpjam-inspector/client/src/components/chatboxes/builder/__tests__/ChatboxBuilderView.test.tsx index 58e66d358..323179093 100644 --- a/mcpjam-inspector/client/src/components/chatboxes/builder/__tests__/ChatboxBuilderView.test.tsx +++ b/mcpjam-inspector/client/src/components/chatboxes/builder/__tests__/ChatboxBuilderView.test.tsx @@ -1,16 +1,38 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { + fireEvent, + render, + screen, + waitFor, + within, +} from "@testing-library/react"; import type { ChatboxSettings } from "@/hooks/useChatboxes"; import { ChatboxBuilderView } from "../ChatboxBuilderView"; import { CHATBOX_STARTERS, toDraftConfig } from "../drafts"; -const { mockUseChatbox, mockChatTabV2 } = vi.hoisted(() => ({ +const { + mockUseChatbox, + mockChatTabV2, + mockCreateChatbox, + mockUpdateChatbox, + mockSetChatboxMode, + mockUpsertChatboxMember, + mockToastError, + mockToastSuccess, +} = vi.hoisted(() => ({ mockUseChatbox: vi.fn(() => ({ chatbox: null })), mockChatTabV2: vi.fn(), + mockCreateChatbox: vi.fn(), + mockUpdateChatbox: vi.fn(), + mockSetChatboxMode: vi.fn(), + mockUpsertChatboxMember: vi.fn(), + mockToastError: vi.fn(), + mockToastSuccess: vi.fn(), })); vi.mock("sonner", () => ({ - toast: { error: vi.fn(), success: vi.fn() }, + toast: { error: mockToastError, success: mockToastSuccess }, })); vi.mock("convex/react", () => ({ @@ -20,9 +42,10 @@ vi.mock("convex/react", () => ({ vi.mock("@/hooks/useChatboxes", () => ({ useChatbox: (...args: unknown[]) => mockUseChatbox(...args), useChatboxMutations: () => ({ - createChatbox: vi.fn(), - updateChatbox: vi.fn(), - setChatboxMode: vi.fn(), + createChatbox: mockCreateChatbox, + updateChatbox: mockUpdateChatbox, + setChatboxMode: mockSetChatboxMode, + upsertChatboxMember: mockUpsertChatboxMember, }), })); @@ -34,6 +57,26 @@ vi.mock("@/hooks/use-mobile", () => ({ useIsMobile: () => false, })); +vi.mock("@workos-inc/authkit-react", () => ({ + useAuth: () => ({ + user: { + firstName: "Ignacio", + lastName: "Jimenez", + email: "ignacio@mcpjam.com", + }, + }), +})); + +vi.mock("@/hooks/useProfilePicture", () => ({ + useProfilePicture: () => ({ profilePictureUrl: null }), +})); + +vi.mock("@/lib/chatbox-host-style", () => ({ + getChatboxHostLogo: () => "/mock-host-logo.png", + getChatboxHostStyleShortLabel: (hostStyle: string) => + hostStyle === "claude" ? "Claude" : "ChatGPT", +})); + vi.mock("@/hooks/hosted/use-hosted-oauth-gate", () => ({ useHostedOAuthGate: () => ({ oauthStateByServerId: {}, @@ -73,11 +116,7 @@ vi.mock("../ChatboxCanvas", () => ({ })); vi.mock("@/components/chatboxes/ChatboxUsagePanel", () => ({ - ChatboxUsagePanel: ({ - section, - }: { - section: "sessions" | "insights"; - }) => ( + ChatboxUsagePanel: ({ section }: { section: "sessions" | "insights" }) => (
), })); @@ -93,7 +132,10 @@ const httpsServer = { updatedAt: 1, }; -function createSavedChatbox(hostStyle: "claude" | "chatgpt"): ChatboxSettings { +function createSavedChatbox( + hostStyle: "claude" | "chatgpt", + overrides: Partial = {} +): ChatboxSettings { return { chatboxId: `sbx-${hostStyle}`, workspaceId: "ws-1", @@ -120,6 +162,16 @@ function createSavedChatbox(hostStyle: "claude" | "chatgpt"): ChatboxSettings { everyNToolCalls: 1, promptHint: "", }, + ...overrides, + }; +} + +function createUnsavedInviteOnlyDraft() { + return { + ...CHATBOX_STARTERS.find((s) => s.id === "internal-qa")!.createDraft( + "openai/gpt-5-mini" + ), + selectedServerIds: [httpsServer._id], }; } @@ -128,6 +180,12 @@ describe("ChatboxBuilderView", () => { mockUseChatbox.mockReset(); mockUseChatbox.mockReturnValue({ chatbox: null }); mockChatTabV2.mockReset(); + mockCreateChatbox.mockReset(); + mockUpdateChatbox.mockReset(); + mockSetChatboxMode.mockReset(); + mockUpsertChatboxMember.mockReset(); + mockToastError.mockReset(); + mockToastSuccess.mockReset(); }); it("shows Save changes on the header save button when a saved chatbox is dirty (no Unsaved badge)", () => { @@ -146,18 +204,18 @@ describe("ChatboxBuilderView", () => { draft={dirtyDraft} onBack={() => {}} onSavedDraft={() => {}} - />, + /> ); expect( - screen.getByRole("button", { name: "Save changes" }), + screen.getByRole("button", { name: "Save changes" }) ).toBeInTheDocument(); expect(screen.queryByText("Unsaved")).not.toBeInTheDocument(); }); it("exposes return navigation as an icon button with a descriptive label", () => { const draft = CHATBOX_STARTERS.find((s) => s.id === "blank")!.createDraft( - "openai/gpt-5-mini", + "openai/gpt-5-mini" ); render( { draft={draft} onBack={() => {}} onSavedDraft={() => {}} - />, + /> ); expect( - screen.getByRole("button", { name: "Return to chatboxes" }), + screen.getByRole("button", { name: "Return to chatboxes" }) ).toBeInTheDocument(); }); it("shows setup-mode bottom CTA only in setup mode", () => { const draft = CHATBOX_STARTERS.find((s) => s.id === "blank")!.createDraft( - "openai/gpt-5-mini", + "openai/gpt-5-mini" ); render( { draft={draft} onBack={() => {}} onSavedDraft={() => {}} - />, + /> ); const cta = screen.getByRole("button", { name: "Save and open preview" }); @@ -196,7 +254,7 @@ describe("ChatboxBuilderView", () => { it("enables the setup bottom CTA when no setup sections need attention", () => { const base = CHATBOX_STARTERS.find((s) => s.id === "blank")!.createDraft( - "openai/gpt-5-mini", + "openai/gpt-5-mini" ); const draft = { ...base, @@ -209,18 +267,195 @@ describe("ChatboxBuilderView", () => { draft={draft} onBack={() => {}} onSavedDraft={() => {}} - />, + /> ); expect( - screen.getByRole("button", { name: "Save and open preview" }), + screen.getByRole("button", { name: "Save and open preview" }) ).not.toBeDisabled(); expect(screen.getByRole("button", { name: /^Save$/i })).not.toBeDisabled(); }); + it("creates the chatbox, sends the staged invite, and reopens Setup > Access on first save", async () => { + const user = userEvent.setup(); + const createdChatbox = createSavedChatbox("claude", { + name: "Internal QA", + mode: "invited_only", + }); + const onSavedDraft = vi.fn(); + mockCreateChatbox.mockResolvedValue(createdChatbox); + mockUpsertChatboxMember.mockResolvedValue(undefined); + + render( + {}} + onSavedDraft={onSavedDraft} + /> + ); + + await user.click(screen.getByRole("button", { name: /Access/i })); + fireEvent.change(screen.getByLabelText(/invite with email/i), { + target: { value: "tester@example.com" }, + }); + await user.click(screen.getByRole("button", { name: /^Invite$/i })); + await user.click(screen.getByRole("button", { name: /^Save$/i })); + + await waitFor(() => expect(mockCreateChatbox).toHaveBeenCalledTimes(1)); + expect(mockCreateChatbox).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: "ws-1", + serverIds: [httpsServer._id], + }) + ); + expect(mockSetChatboxMode).not.toHaveBeenCalled(); + await waitFor(() => + expect(mockUpsertChatboxMember).toHaveBeenCalledWith({ + chatboxId: createdChatbox.chatboxId, + email: "tester@example.com", + sendInviteEmail: true, + }) + ); + expect(mockCreateChatbox.mock.invocationCallOrder[0]).toBeLessThan( + mockUpsertChatboxMember.mock.invocationCallOrder[0] + ); + await waitFor(() => + expect(onSavedDraft).toHaveBeenCalledWith(createdChatbox, { + initialViewMode: "setup", + initialFocusedSetupSection: "access", + }) + ); + }); + + it("sends the staged invite and honors Save and open preview", async () => { + const user = userEvent.setup(); + const createdChatbox = createSavedChatbox("claude", { + name: "Internal QA", + mode: "invited_only", + }); + const onSavedDraft = vi.fn(); + mockCreateChatbox.mockResolvedValue(createdChatbox); + mockUpsertChatboxMember.mockResolvedValue(undefined); + + render( + {}} + onSavedDraft={onSavedDraft} + /> + ); + + await user.click(screen.getByRole("button", { name: /Access/i })); + fireEvent.change(screen.getByLabelText(/invite with email/i), { + target: { value: "preview@example.com" }, + }); + await user.click(screen.getByRole("button", { name: /^Invite$/i })); + await user.click( + screen.getByRole("button", { name: "Save and open preview" }) + ); + + await waitFor(() => expect(mockCreateChatbox).toHaveBeenCalledTimes(1)); + await waitFor(() => + expect(mockUpsertChatboxMember).toHaveBeenCalledWith({ + chatboxId: createdChatbox.chatboxId, + email: "preview@example.com", + sendInviteEmail: true, + }) + ); + expect(mockCreateChatbox.mock.invocationCallOrder[0]).toBeLessThan( + mockUpsertChatboxMember.mock.invocationCallOrder[0] + ); + await waitFor(() => + expect(onSavedDraft).toHaveBeenCalledWith(createdChatbox, { + initialViewMode: "preview", + }) + ); + }); + + it("keeps the staged invite email in place when save is blocked and does not attempt an invite", async () => { + const user = userEvent.setup(); + const draft = CHATBOX_STARTERS.find( + (s) => s.id === "internal-qa" + )!.createDraft("openai/gpt-5-mini"); + render( + {}} + onSavedDraft={() => {}} + /> + ); + + await user.click(screen.getByRole("button", { name: /Access/i })); + const emailInput = screen.getByLabelText(/invite with email/i); + fireEvent.change(emailInput, { + target: { value: "blocked@example.com" }, + }); + + const saveButton = screen.getByRole("button", { name: /^Save$/i }); + expect(saveButton).toBeDisabled(); + await user.click(saveButton); + + expect(emailInput).toHaveValue("blocked@example.com"); + expect(mockCreateChatbox).not.toHaveBeenCalled(); + expect(mockUpsertChatboxMember).not.toHaveBeenCalled(); + }); + + it("keeps the created chatbox and reopens Setup > Access when the staged invite fails", async () => { + const user = userEvent.setup(); + const createdChatbox = createSavedChatbox("claude", { + name: "Internal QA", + mode: "invited_only", + }); + const onSavedDraft = vi.fn(); + mockCreateChatbox.mockResolvedValue(createdChatbox); + mockUpsertChatboxMember.mockRejectedValue(new Error("Invite failed")); + + render( + {}} + onSavedDraft={onSavedDraft} + /> + ); + + await user.click(screen.getByRole("button", { name: /Access/i })); + fireEvent.change(screen.getByLabelText(/invite with email/i), { + target: { value: "retry@example.com" }, + }); + await user.click(screen.getByRole("button", { name: /^Invite$/i })); + await user.click( + screen.getByRole("button", { name: "Save and open preview" }) + ); + + await waitFor(() => expect(mockCreateChatbox).toHaveBeenCalledTimes(1)); + await waitFor(() => + expect(mockUpsertChatboxMember).toHaveBeenCalledWith({ + chatboxId: createdChatbox.chatboxId, + email: "retry@example.com", + sendInviteEmail: true, + }) + ); + await waitFor(() => + expect(onSavedDraft).toHaveBeenCalledWith(createdChatbox, { + initialViewMode: "setup", + initialFocusedSetupSection: "access", + }) + ); + expect(mockToastSuccess).toHaveBeenCalledWith("Chatbox created"); + expect(mockToastError).toHaveBeenCalledWith("Invite failed"); + }); + it("disables Preview, Sessions, and Clusters until the chatbox is saved", () => { const draft = CHATBOX_STARTERS.find((s) => s.id === "blank")!.createDraft( - "openai/gpt-5-mini", + "openai/gpt-5-mini" ); render( { draft={draft} onBack={() => {}} onSavedDraft={() => {}} - />, + /> ); expect(screen.getByRole("button", { name: "Preview" })).toBeDisabled(); @@ -249,12 +484,12 @@ describe("ChatboxBuilderView", () => { initialViewMode="usage" onBack={() => {}} onSavedDraft={() => {}} - />, + /> ); expect(screen.getByTestId("chatbox-usage-panel")).toHaveAttribute( "data-section", - "sessions", + "sessions" ); }); @@ -270,18 +505,18 @@ describe("ChatboxBuilderView", () => { initialViewMode="insights" onBack={() => {}} onSavedDraft={() => {}} - />, + /> ); expect(screen.getByTestId("chatbox-usage-panel")).toHaveAttribute( "data-section", - "insights", + "insights" ); }); it("renders the setup checklist on desktop while in setup mode", () => { const draft = CHATBOX_STARTERS.find((s) => s.id === "blank")!.createDraft( - "openai/gpt-5-mini", + "openai/gpt-5-mini" ); render( { draft={draft} onBack={() => {}} onSavedDraft={() => {}} - />, + /> ); expect(screen.getByRole("button", { name: /Basics/i })).toBeInTheDocument(); @@ -309,18 +544,18 @@ describe("ChatboxBuilderView", () => { initialViewMode="preview" onBack={() => {}} onSavedDraft={() => {}} - />, + /> ); const rail = screen.getByTestId("chatbox-builder-preview-rail-actions"); expect( - within(rail).getByRole("button", { name: "Copy link" }), + within(rail).getByRole("button", { name: "Copy link" }) ).toBeInTheDocument(); expect( - within(rail).getByRole("button", { name: "Open full preview" }), + within(rail).getByRole("button", { name: "Open full preview" }) ).toBeInTheDocument(); expect( - within(rail).getByRole("button", { name: "Reload preview" }), + within(rail).getByRole("button", { name: "Reload preview" }) ).toBeInTheDocument(); }); @@ -336,14 +571,14 @@ describe("ChatboxBuilderView", () => { initialViewMode="preview" onBack={() => {}} onSavedDraft={() => {}} - />, + /> ); expect(screen.getByTestId("chat-tab")).toBeInTheDocument(); expect(mockChatTabV2).toHaveBeenCalledWith( expect.objectContaining({ loadingIndicatorVariant: "chatgpt-dot", - }), + }) ); }); @@ -359,14 +594,14 @@ describe("ChatboxBuilderView", () => { initialViewMode="preview" onBack={() => {}} onSavedDraft={() => {}} - />, + /> ); expect(screen.getByTestId("chat-tab")).toBeInTheDocument(); expect(mockChatTabV2).toHaveBeenCalledWith( expect.objectContaining({ loadingIndicatorVariant: "claude-mark", - }), + }) ); }); }); diff --git a/mcpjam-inspector/client/src/components/chatboxes/builder/__tests__/setup-checklist-panel.test.tsx b/mcpjam-inspector/client/src/components/chatboxes/builder/__tests__/setup-checklist-panel.test.tsx index 4ad7beff8..cd675e7ed 100644 --- a/mcpjam-inspector/client/src/components/chatboxes/builder/__tests__/setup-checklist-panel.test.tsx +++ b/mcpjam-inspector/client/src/components/chatboxes/builder/__tests__/setup-checklist-panel.test.tsx @@ -1,4 +1,5 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; +import userEvent from "@testing-library/user-event"; import { fireEvent, render, screen, within } from "@testing-library/react"; import { computeSectionStatuses, @@ -8,14 +9,40 @@ import { import { CHATBOX_STARTERS } from "../drafts"; import type { RemoteServer } from "@/hooks/useWorkspaces"; +vi.mock("@/lib/chatbox-host-style", () => ({ + getChatboxHostLogo: () => "/mock-host-logo.png", + getChatboxHostStyleShortLabel: (hostStyle: string) => + hostStyle === "claude" ? "Claude" : "ChatGPT", +})); + +vi.mock("@workos-inc/authkit-react", () => ({ + useAuth: () => ({ + user: { + firstName: "Ignacio", + lastName: "Jimenez", + email: "ignacio@mcpjam.com", + }, + }), +})); + +vi.mock("@/hooks/useProfilePicture", () => ({ + useProfilePicture: () => ({ profilePictureUrl: null }), +})); + const baseDraft = CHATBOX_STARTERS.find((s) => s.id === "blank")!.createDraft( - "openai/gpt-5-mini", + "openai/gpt-5-mini" ); +const stagedInviteProps = { + stagedAccessInviteEmails: [], + onStagedAccessInviteEmailAdd: vi.fn(), + onStagedAccessInviteEmailRemove: vi.fn(), +}; describe("SetupChecklistPanel", () => { it("does not render the Setup header row on desktop (no onCloseMobile)", () => { render( { onDraftChange={() => {}} onOpenAddServer={() => {}} onToggleServer={() => {}} - />, + /> ); expect( - screen.queryByRole("heading", { name: "Setup" }), + screen.queryByRole("heading", { name: "Setup" }) ).not.toBeInTheDocument(); expect(screen.getByRole("button", { name: /Basics/i })).toBeInTheDocument(); }); @@ -36,6 +63,7 @@ describe("SetupChecklistPanel", () => { it("shows a subdued checkmark label with Done for complete sections (not a colored pill)", () => { render( { onDraftChange={() => {}} onOpenAddServer={() => {}} onToggleServer={() => {}} - />, + /> ); const basicsRow = screen.getByRole("button", { name: /Basics/i }); expect(within(basicsRow).getByText("Done")).toBeInTheDocument(); expect( - within(basicsRow).queryByText("Complete", { exact: true }), + within(basicsRow).queryByText("Complete", { exact: true }) ).not.toBeInTheDocument(); }); it("uses the same muted inline template for Optional, Default on, and Collapsed (no secondary badges)", () => { render( { onDraftChange={() => {}} onOpenAddServer={() => {}} onToggleServer={() => {}} - />, + /> ); const welcomeRow = screen.getByRole("button", { @@ -77,7 +106,9 @@ describe("SetupChecklistPanel", () => { expect(within(welcomeRow).getByText("Optional")).toBeInTheDocument(); expect(welcomeRow.querySelector('[data-slot="badge"]')).toBeNull(); - const feedbackRow = screen.getByRole("button", { name: /Feedback Default on/i }); + const feedbackRow = screen.getByRole("button", { + name: /Feedback Default on/i, + }); expect(within(feedbackRow).getByText("Default on")).toBeInTheDocument(); expect(feedbackRow.querySelector('[data-slot="badge"]')).toBeNull(); @@ -91,6 +122,7 @@ describe("SetupChecklistPanel", () => { it("renders mobile Done header when onCloseMobile is provided", () => { render( { onOpenAddServer={() => {}} onToggleServer={() => {}} onCloseMobile={() => {}} - />, + /> ); expect(screen.getByRole("heading", { name: "Setup" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Done" })).toBeInTheDocument(); }); - it("shows draft access controls inline in Access (no General access heading)", () => { + it("shows draft access controls inline in Access (no General access heading)", async () => { + const user = userEvent.setup(); render( { onDraftChange={() => {}} onOpenAddServer={() => {}} onToggleServer={() => {}} - />, + /> ); fireEvent.click(screen.getByRole("button", { name: /Access/i })); expect(screen.queryByText("General access")).not.toBeInTheDocument(); expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); - expect(screen.getByText("Acme")).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Acme" })); expect(screen.getByText("Invited users only")).toBeInTheDocument(); expect( - screen.getByText("Anyone with the link (guests included)"), + screen.getByText("Anyone with the link (guests included)") ).toBeInTheDocument(); }); - it("shows invite-only save prompt in Access when chatbox is unsaved", () => { + it("shows enabled Invite button pre-save and disabled when input is empty", () => { const internalDraft = CHATBOX_STARTERS.find( - (s) => s.id === "internal-qa", + (s) => s.id === "internal-qa" )!.createDraft("openai/gpt-5-mini"); render( { onDraftChange={() => {}} onOpenAddServer={() => {}} onToggleServer={() => {}} - />, + /> ); fireEvent.click(screen.getByRole("button", { name: /Access/i })); - expect( - screen.getByText(/Save the chatbox to invite people by email/i), - ).toBeInTheDocument(); - const emailInput = screen.getByLabelText(/email address/i); - expect(emailInput).toBeDisabled(); - expect(screen.getByRole("button", { name: /^Invite$/i })).toBeDisabled(); + const emailInput = screen.getByLabelText(/invite with email/i); + expect(emailInput).toBeEnabled(); + const inviteButton = screen.getByRole("button", { name: /^Invite$/i }); + expect(inviteButton).toBeDisabled(); + + fireEvent.change(emailInput, { target: { value: "test@example.com" } }); + expect(inviteButton).toBeEnabled(); + }); + + it("calls onStagedAccessInviteEmailAdd when Invite is clicked pre-save", () => { + const onAdd = vi.fn(); + const internalDraft = CHATBOX_STARTERS.find( + (s) => s.id === "internal-qa" + )!.createDraft("openai/gpt-5-mini"); + render( + {}} + onOpenAddServer={() => {}} + onToggleServer={() => {}} + /> + ); + + fireEvent.click(screen.getByRole("button", { name: /Access/i })); + fireEvent.change(screen.getByLabelText(/invite with email/i), { + target: { value: "hello@example.com" }, + }); + fireEvent.click(screen.getByRole("button", { name: /^Invite$/i })); + expect(onAdd).toHaveBeenCalledWith("hello@example.com"); + }); + + it("renders staged emails as a removable list", async () => { + const user = userEvent.setup(); + const onRemove = vi.fn(); + const internalDraft = CHATBOX_STARTERS.find( + (s) => s.id === "internal-qa" + )!.createDraft("openai/gpt-5-mini"); + render( + {}} + onOpenAddServer={() => {}} + onToggleServer={() => {}} + /> + ); + + fireEvent.click(screen.getByRole("button", { name: /Access/i })); + expect(screen.getByText("alice@example.com")).toBeInTheDocument(); + expect(screen.getByText("bob@example.com")).toBeInTheDocument(); + await user.click(screen.getAllByRole("button", { name: /Pending/i })[0]); + await user.click(screen.getByRole("menuitem", { name: /Cancel invite/i })); + expect(onRemove).toHaveBeenCalledWith("alice@example.com"); }); it("shows invite email field when inviteChatboxMember is wired (e.g. saved chatbox id)", () => { const internalDraft = CHATBOX_STARTERS.find( - (s) => s.id === "internal-qa", + (s) => s.id === "internal-qa" )!.createDraft("openai/gpt-5-mini"); render( { onOpenAddServer={() => {}} onToggleServer={() => {}} inviteChatboxMember={async () => {}} - />, + /> ); fireEvent.click(screen.getByRole("button", { name: /Access/i })); - expect(screen.getByText("Invite people")).toBeInTheDocument(); - expect( - screen.getByPlaceholderText(/colleague@company.com/i), - ).toBeInTheDocument(); + expect(screen.getByLabelText(/invite with email/i)).toBeInTheDocument(); + expect(screen.getByPlaceholderText(/add people/i)).toBeInTheDocument(); }); }); @@ -212,7 +306,7 @@ describe("ServerSelectionEditor", () => { selectedServerIds={[httpServer._id, httpServerB._id]} onToggleSelection={() => {}} onOpenAdd={() => {}} - />, + /> ); expect(screen.getByText("Linear MCP")).toBeInTheDocument(); diff --git a/mcpjam-inspector/client/src/components/chatboxes/builder/setup-checklist-panel.tsx b/mcpjam-inspector/client/src/components/chatboxes/builder/setup-checklist-panel.tsx index fc4ba3970..30c49b5db 100644 --- a/mcpjam-inspector/client/src/components/chatboxes/builder/setup-checklist-panel.tsx +++ b/mcpjam-inspector/client/src/components/chatboxes/builder/setup-checklist-panel.tsx @@ -1,6 +1,21 @@ import { useEffect, useMemo, useRef, useState } from "react"; -import { Check, ChevronDown, Loader2, Plus } from "lucide-react"; +import { + Check, + ChevronDown, + Clock, + Globe, + Loader2, + Lock, + Plus, + Users, +} from "lucide-react"; +import { useAuth } from "@workos-inc/authkit-react"; import { toast } from "sonner"; +import { + Avatar, + AvatarFallback, + AvatarImage, +} from "@mcpjam/design-system/avatar"; import { Badge } from "@mcpjam/design-system/badge"; import { Button } from "@mcpjam/design-system/button"; import { Card } from "@mcpjam/design-system/card"; @@ -17,7 +32,14 @@ import { PopoverContent, PopoverTrigger, } from "@mcpjam/design-system/popover"; -import { RadioGroup, RadioGroupItem } from "@mcpjam/design-system/radio-group"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuTrigger, +} from "@mcpjam/design-system/dropdown-menu"; import { Select, SelectContent, @@ -32,6 +54,7 @@ import { Textarea } from "@mcpjam/design-system/textarea"; import { ScrollArea } from "@mcpjam/design-system/scroll-area"; import { ChatboxShareSection } from "@/components/chatboxes/ChatboxShareSection"; import type { ChatboxSettings } from "@/hooks/useChatboxes"; +import { useProfilePicture } from "@/hooks/useProfilePicture"; import { chatboxAccessPresetFromSettings, settingsFromChatboxAccessPreset, @@ -44,7 +67,7 @@ import { type ChatboxHostStyle, } from "@/lib/chatbox-host-style"; import { isMCPJamProvidedModel, SUPPORTED_MODELS } from "@/shared/types"; -import { cn } from "@/lib/utils"; +import { cn, getInitials } from "@/lib/utils"; import type { ChatboxDraftConfig } from "./types"; export type SetupSectionId = @@ -64,6 +87,7 @@ type SectionStatusKind = const sectionStatusMetaClassName = "inline-flex items-center gap-1.5 text-xs font-medium text-muted-foreground"; +const INVITE_EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; function SectionStatusBadge({ kind }: { kind: SectionStatusKind }) { switch (kind) { @@ -98,7 +122,7 @@ function SetupSectionStepIndex({ step }: { step: number }) { return ( {step} @@ -147,7 +171,7 @@ function isInsecureUrl(url: string | undefined): boolean { function updateSelectedServerIds( currentServerIds: string[], serverId: string, - checked: boolean, + checked: boolean ): string[] { const hasServer = currentServerIds.includes(serverId); if (checked) { @@ -169,7 +193,7 @@ export function WorkspaceServerPickerList({ onToggleSelection: (serverId: string, checked: boolean) => void; }) { const availableServers = workspaceServers.filter( - (server) => server.transportType === "http", + (server) => server.transportType === "http" ); const selectedServerSet = new Set(selectedServerIds); @@ -188,7 +212,9 @@ export function WorkspaceServerPickerList({ return (
@@ -633,110 +713,106 @@ export function SetupChecklistPanel({ workspaceName={workspaceName} /> ) : ( - <> -
- - onDraftChange((draft) => ({ - ...draft, - ...settingsFromChatboxAccessPreset( - value as ChatboxAccessPreset, - ), - })) - } - className="grid gap-2" - > - -