diff --git a/.gitignore b/.gitignore index 4d3d5ead..111494f6 100644 --- a/.gitignore +++ b/.gitignore @@ -71,6 +71,7 @@ src-tauri/gen/apple/ .cache/ .yalc .yalc.lock +yalc.lock # signing artifacts (never commit provisioning profiles) *.provisionprofile diff --git a/app/globals.css b/app/globals.css index c2a20944..afe7d964 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,4 +1,5 @@ @import 'tailwindcss'; +@source "../node_modules/@iblai/web-containers/dist"; @import 'tw-animate-css'; @tailwind utilities; diff --git a/app/onboarding/__tests__/page.test.tsx b/app/onboarding/__tests__/page.test.tsx new file mode 100644 index 00000000..77f03fcb --- /dev/null +++ b/app/onboarding/__tests__/page.test.tsx @@ -0,0 +1,96 @@ +import React from 'react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; + +const { state, push } = vi.hoisted(() => ({ + state: { tenantKey: 'acme' }, + push: vi.fn(), +})); + +vi.mock('@/hooks/use-onboarding', () => ({ + useOnboardingAccess: () => ({ canAccess: true, tenantKey: state.tenantKey }), +})); + +vi.mock('@/hooks/use-user', () => ({ useUsername: () => 'tester' })); +vi.mock('next/navigation', () => ({ useRouter: () => ({ push }) })); + +vi.mock('@/lib/initial-loader', () => ({ hideInitialLoader: vi.fn() })); +vi.mock('@/components/spinner', () => ({ Spinner: () =>
spinner
})); + +// The OS-local final screen; stub it so the page test doesn't pull the data layer. +vi.mock('@/components/onboarding/onboarding-create-agent-step', () => ({ + OnboardingCreateAgentStep: () =>
create-agent-step
, +})); + +// The route renders the SDK wizard from @iblai/iblai-js/web-containers; stub it, +// expose onComplete, and render whatever final step the page passes. The wizard +// persists the answers itself (default), so the page no longer wires that. +vi.mock('@iblai/iblai-js/web-containers', () => ({ + OnboardingWizard: ({ + tenant, + username, + onComplete, + renderFinalStep, + }: { + tenant: string; + username: string; + onComplete: (agentId: string | null) => void; + renderFinalStep?: (context: unknown) => React.ReactNode; + }) => ( +
+
+ onboarding-wizard:{tenant}:{username} +
+ + +
+ {renderFinalStep?.({ + tenant, + username, + answers: { organizationName: '', sector: null }, + goBack: () => {}, + complete: () => {}, + })} +
+
+ ), +})); + +import OnboardingPage from '../page'; + +describe('OnboardingPage', () => { + beforeEach(() => { + vi.clearAllMocks(); + state.tenantKey = 'acme'; + }); + + it('renders the SDK wizard with the tenant + username and the OS create-agent final step', () => { + render(); + expect( + screen.getByText('onboarding-wizard:acme:tester'), + ).toBeInTheDocument(); + expect(screen.getByText('create-agent-step')).toBeInTheDocument(); + }); + + it('shows a loader while the current tenant resolves', () => { + state.tenantKey = ''; + render(); + expect(screen.getByText('spinner')).toBeInTheDocument(); + expect(screen.queryByText(/onboarding-wizard/)).not.toBeInTheDocument(); + }); + + it('redirects to the agent explore page on complete (tenant-level when no agent)', () => { + render(); + fireEvent.click( + screen.getByRole('button', { name: 'complete-with-agent' }), + ); + expect(push).toHaveBeenCalledWith('/platform/acme/agent-1/explore'); + + fireEvent.click(screen.getByRole('button', { name: 'complete-no-agent' })); + expect(push).toHaveBeenCalledWith('/platform/acme/explore'); + }); +}); diff --git a/app/onboarding/page.tsx b/app/onboarding/page.tsx new file mode 100644 index 00000000..9206f20d --- /dev/null +++ b/app/onboarding/page.tsx @@ -0,0 +1,53 @@ +'use client'; + +import { useEffect } from 'react'; +import { useRouter } from 'next/navigation'; +import { OnboardingWizard } from '@iblai/iblai-js/web-containers'; + +import { Spinner } from '@/components/spinner'; +import { hideInitialLoader } from '@/lib/initial-loader'; +import { useOnboardingAccess } from '@/hooks/use-onboarding'; +import { useUsername } from '@/hooks/use-user'; +import { OnboardingCreateAgentStep } from '@/components/onboarding/onboarding-create-agent-step'; + +/** + * The `/onboarding` route renders the SDK onboarding wizard + * (`@iblai/iblai-js/web-containers`). The app supplies identity (tenant + + * username) and the create-agent final step; the wizard persists the answers + * itself (under the user's platform metadata `onboarding` key) since no + * `onAnswersSubmit` is passed. On completion it redirects to an agent's explore + * page. It performs NO automatic redirects into onboarding; access gating is + * wired up elsewhere. While the current tenant resolves, a brief loader shows. + */ +export default function OnboardingPage() { + const { tenantKey } = useOnboardingAccess(); + const username = useUsername(); + const router = useRouter(); + + useEffect(() => { + hideInitialLoader(); + }, []); + + if (!tenantKey) { + return ( +
+ +
+ ); + } + + return ( + + router.push( + agentId + ? `/platform/${tenantKey}/${agentId}/explore` + : `/platform/${tenantKey}/explore`, + ) + } + renderFinalStep={(context) => } + /> + ); +} diff --git a/components/onboarding/__tests__/onboarding-create-agent-step.test.tsx b/components/onboarding/__tests__/onboarding-create-agent-step.test.tsx new file mode 100644 index 00000000..b01b2459 --- /dev/null +++ b/components/onboarding/__tests__/onboarding-create-agent-step.test.tsx @@ -0,0 +1,115 @@ +import React from 'react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; + +const { mockCreateMentor, mockUnwrap } = vi.hoisted(() => ({ + mockCreateMentor: vi.fn(), + mockUnwrap: vi.fn(), +})); +const mockUseCreateMentorMutation = vi.fn(); +const mockUseGetMentorsQuery = vi.fn(); + +vi.mock('@iblai/iblai-js/data-layer', () => ({ + useCreateMentorMutation: (...args: unknown[]) => + mockUseCreateMentorMutation(...args), + useGetMentorsQuery: (...args: unknown[]) => mockUseGetMentorsQuery(...args), +})); + +// Stub the SDK shell pieces so the test doesn't load the whole web-containers bundle. +vi.mock('@iblai/iblai-js/web-containers', () => ({ + StepHeader: ({ title }: { title: string }) =>

{title}

, + onboardingPrimaryButtonClass: 'btn', +})); + +vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })); +vi.mock('@/lib/config', () => ({ + config: { iblTemplateMentor: () => 'ai-mentor' }, +})); + +import { OnboardingCreateAgentStep } from '../onboarding-create-agent-step'; + +const baseContext = { + tenant: 'acme', + username: 'tester', + answers: { organizationName: 'Acme', segment: 'higher_education' }, + suggestedAgent: { name: 'Campus Assistant', description: 'Helps students.' }, + goBack: vi.fn(), +}; + +beforeEach(() => { + vi.clearAllMocks(); + mockUnwrap.mockResolvedValue({ unique_id: 'new-agent' }); + mockCreateMentor.mockReturnValue({ unwrap: mockUnwrap }); + mockUseCreateMentorMutation.mockReturnValue([ + mockCreateMentor, + { isLoading: false }, + ]); + mockUseGetMentorsQuery.mockReturnValue({ + data: { count: 1, results: [{ unique_id: 'existing-agent' }] }, + isLoading: false, + }); +}); + +describe('OnboardingCreateAgentStep', () => { + it('prefills name + description from the segment suggestion', () => { + render(); + expect(screen.getByText('Make your first agent')).toBeInTheDocument(); + expect((screen.getByLabelText('Name') as HTMLInputElement).value).toBe( + 'Campus Assistant', + ); + expect( + (screen.getByLabelText('Description') as HTMLTextAreaElement).value, + ).toBe('Helps students.'); + }); + + it('kicks off creation without waiting and completes with an existing agent id', async () => { + // Slow endpoint — never resolves, to prove we don't wait for it. + mockUnwrap.mockReturnValue(new Promise(() => {})); + const complete = vi.fn(); + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Create agent' })); + + await waitFor(() => expect(mockCreateMentor).toHaveBeenCalledTimes(1)); + const payload = mockCreateMentor.mock.calls[0][0]; + expect(payload.org).toBe('acme'); + expect(payload.userId).toBe('tester'); + expect(payload.formData.new_mentor_name).toBe('Campus Assistant'); + expect(payload.formData.mentor_visibility).toBe( + 'viewable_by_tenant_students', + ); + expect(payload.formData.metadata).toEqual({ category: null }); + expect(payload.formData.categories).toBeUndefined(); + + // Completes IMMEDIATELY with the EXISTING agent's id (not the new one). + expect(complete).toHaveBeenCalledWith('existing-agent'); + }); + + it('completes with null when the tenant has no agents yet', () => { + mockUseGetMentorsQuery.mockReturnValue({ + data: { count: 0, results: [] }, + isLoading: false, + }); + const complete = vi.fn(); + render(); + fireEvent.click(screen.getByRole('button', { name: 'Create agent' })); + expect(complete).toHaveBeenCalledWith(null); + }); + + it('swaps the form for a loading state once submitted', () => { + // Slow endpoint that never resolves — the loading state must show regardless. + mockUnwrap.mockReturnValue(new Promise(() => {})); + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Create agent' })); + + expect(screen.getByRole('status')).toHaveTextContent( + 'Creating your agent…', + ); + // The form is replaced. + expect(screen.queryByLabelText('Name')).not.toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: 'Create agent' }), + ).not.toBeInTheDocument(); + }); +}); diff --git a/components/onboarding/onboarding-create-agent-step.tsx b/components/onboarding/onboarding-create-agent-step.tsx new file mode 100644 index 00000000..58138394 --- /dev/null +++ b/components/onboarding/onboarding-create-agent-step.tsx @@ -0,0 +1,187 @@ +'use client'; + +import { useState } from 'react'; +import { Loader2, Sparkles } from 'lucide-react'; +import { toast } from 'sonner'; +import { + useCreateMentorMutation, + useGetMentorsQuery, +} from '@iblai/iblai-js/data-layer'; +import { + StepHeader, + onboardingPrimaryButtonClass, + type OnboardingFinalStepContext, +} from '@iblai/iblai-js/web-containers'; + +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { + DEFAULT_PROMPTS, + MENTOR_VISIBILITY_VALUES, + MODEL_AGENTS, +} from '@/lib/constants'; +import { config } from '@/lib/config'; + +/** + * OS-local final screen for onboarding: a minimal Name + Description form that + * creates the first agent with the app's defaults (Students visibility, default + * prompts, neutral template, no category). It is wired into the SDK + * `OnboardingWizard` via its `renderFinalStep` slot (the SDK ships no built-in + * final screen — the create page lives here, in the OS app, only). + * + * The create endpoint is slow, so submission is fire-and-forget: the mutation + * is kicked off and the wizard completes IMMEDIATELY with an existing agent's + * id (any agent, fetched up front) so the page redirects without waiting — + * `null` when the tenant has no agents yet. A failed creation surfaces as a + * deferred toast. + */ +export function OnboardingCreateAgentStep({ + tenant, + username, + suggestedAgent, + goBack, + complete, +}: OnboardingFinalStepContext) { + const [name, setName] = useState(suggestedAgent?.name ?? ''); + const [description, setDescription] = useState( + suggestedAgent?.description ?? '', + ); + // Flipped on submit so the form is swapped for a loading state while the page + // redirects to the new workspace (creation itself is fire-and-forget). + const [submitting, setSubmitting] = useState(false); + const [createMentor, { isLoading }] = useCreateMentorMutation(); + + // Any existing agent's id, fetched up front — what the page redirects to the + // moment creation is kicked off. + const { data: existingAgents, isLoading: isLoadingAgents } = + useGetMentorsQuery( + { org: tenant, username, limit: 1, offset: 0 }, + { skip: !tenant || !username }, + ); + const anyAgentId: string | null = + (existingAgents?.results?.[0] as { unique_id?: string } | undefined) + ?.unique_id ?? null; + + const canCreate = name.trim().length > 0 && !isLoading && !isLoadingAgents; + + const submit = () => { + if (!canCreate) return; + // Swap to the loading state up front, then kick the (slow) create off and + // leave immediately — the redirect happens while this spinner is showing. + setSubmitting(true); + createMentor({ + org: tenant, + formData: { + new_mentor_name: name.trim(), + display_name: name.trim(), + description: description.trim(), + template_name: MODEL_AGENTS[0]?.value || config.iblTemplateMentor(), + metadata: { category: null }, + system_prompt: DEFAULT_PROMPTS.DEFAULT_SYSTEM_PROMPT, + proactive_prompt: DEFAULT_PROMPTS.DEFAULT_PROACTIVE_PROMPT, + moderation_system_prompt: DEFAULT_PROMPTS.DEFAULT_MODERATION_PROMPT, + guided_prompt_instructions: DEFAULT_PROMPTS.DEFAULT_GUIDED_PROMPT, + mentor_visibility: MENTOR_VISIBILITY_VALUES.STUDENTS, + }, + // @ts-ignore userId is accepted by the API but absent from the RTK type + userId: username, + }) + .unwrap() + .catch((error: unknown) => { + const message = + (error as { error?: { error?: string } })?.error?.error ?? + 'Failed to create agent'; + toast.error(message); + }); + complete(anyAgentId); + }; + + if (submitting) { + return ( +
+
+ ); + } + + return ( +
+ +
{ + event.preventDefault(); + submit(); + }} + className="space-y-5" + > +
+ + setName(event.target.value)} + placeholder="Campus Assistant" + autoFocus + className="h-11" + /> +
+
+ +