From f8658cc28b5e9942c563e662734071f0a8ba10d3 Mon Sep 17 00:00:00 2001 From: Roomote Date: Wed, 5 Aug 2026 14:17:31 +0000 Subject: [PATCH] feat: add two-lane onboarding pane --- .../(authenticated)/home/Home.client.test.tsx | 4 +- .../web/src/app/(authenticated)/home/Home.tsx | 13 +- ...est.tsx => OnboardingPane.client.test.tsx} | 138 ++++++-- ...{OnboardingCard.tsx => OnboardingPane.tsx} | 305 +++++++++++++----- .../(onboarding)/setup/SetupSignedInFlow.tsx | 5 - .../setup/StepInvoke.client.test.tsx | 12 - .../src/app/(onboarding)/setup/StepInvoke.tsx | 6 - 7 files changed, 336 insertions(+), 147 deletions(-) rename apps/web/src/app/(authenticated)/home/{OnboardingCard.client.test.tsx => OnboardingPane.client.test.tsx} (68%) rename apps/web/src/app/(authenticated)/home/{OnboardingCard.tsx => OnboardingPane.tsx} (71%) diff --git a/apps/web/src/app/(authenticated)/home/Home.client.test.tsx b/apps/web/src/app/(authenticated)/home/Home.client.test.tsx index 5f4a4d176..c86d6398b 100644 --- a/apps/web/src/app/(authenticated)/home/Home.client.test.tsx +++ b/apps/web/src/app/(authenticated)/home/Home.client.test.tsx @@ -113,8 +113,8 @@ vi.mock('@/lib/utils', () => ({ classNames.filter(Boolean).join(' '), })); -vi.mock('./OnboardingCard', () => ({ - OnboardingCard: () =>
Onboarding
, +vi.mock('./OnboardingPane', () => ({ + OnboardingPane: () =>
Onboarding
, })); vi.mock('./BottomSheetTabs', () => ({ diff --git a/apps/web/src/app/(authenticated)/home/Home.tsx b/apps/web/src/app/(authenticated)/home/Home.tsx index 18dc0b839..38ecf1d41 100644 --- a/apps/web/src/app/(authenticated)/home/Home.tsx +++ b/apps/web/src/app/(authenticated)/home/Home.tsx @@ -52,7 +52,7 @@ import { AUTO_WORKSPACE_VALUE, } from '@/components/tasks'; -import { OnboardingCard } from './OnboardingCard'; +import { OnboardingPane } from './OnboardingPane'; import { BottomSheetTabs } from './BottomSheetTabs'; import { HOME_PROMPT_PLACEHOLDERS, @@ -605,14 +605,14 @@ export function Home({
@@ -711,12 +711,9 @@ export function Home({ )}
- -
- -
-
+
+
diff --git a/apps/web/src/app/(authenticated)/home/OnboardingCard.client.test.tsx b/apps/web/src/app/(authenticated)/home/OnboardingPane.client.test.tsx similarity index 68% rename from apps/web/src/app/(authenticated)/home/OnboardingCard.client.test.tsx rename to apps/web/src/app/(authenticated)/home/OnboardingPane.client.test.tsx index 9bd7e6421..971ea9eb0 100644 --- a/apps/web/src/app/(authenticated)/home/OnboardingCard.client.test.tsx +++ b/apps/web/src/app/(authenticated)/home/OnboardingPane.client.test.tsx @@ -9,6 +9,8 @@ let linkedMcpIds: string[] = []; let orgHasLinear = false; let userHasLinkedLinear = false; let hasEnabledAutomations = true; +let onboardingPending = false; +let automationsPending = false; const { mockPush, @@ -38,7 +40,7 @@ vi.mock('@/hooks/useUser', () => ({ vi.mock('@/hooks/mcp-connections', () => ({ useDeploymentMcpEnablements: () => ({ data: enabledMcpIds.map((mcpId) => ({ mcpId, enabled: true })), - isPending: false, + isPending: onboardingPending, }), useUserMcpConnections: () => ({ data: linkedMcpIds.map((mcpId) => ({ mcpId, authStatus: 'authenticated' })), @@ -102,9 +104,9 @@ vi.mock('@tanstack/react-query', () => ({ orgHasLinear, userHasLinkedLinear, }, - isPending: false, + isPending: onboardingPending, } - : { data: { hasEnabledAutomations }, isPending: false }, + : { data: { hasEnabledAutomations }, isPending: automationsPending }, })); vi.mock('motion/react', async () => { @@ -151,7 +153,7 @@ vi.mock('@/components/settings/TelegramLinkAccountStep', () => ({ TelegramLinkAccountStep: () =>
Telegram link flow
, })); -import { OnboardingCard } from './OnboardingCard'; +import { OnboardingPane } from './OnboardingPane'; function dismissCard() { fireEvent.click(screen.getAllByRole('button', { name: 'Dismiss' })[0]!); @@ -165,6 +167,8 @@ beforeEach(() => { orgHasLinear = false; userHasLinkedLinear = false; hasEnabledAutomations = true; + onboardingPending = false; + automationsPending = false; localStorage.clear(); vi.clearAllMocks(); }); @@ -181,7 +185,7 @@ it('prioritizes automations and opens the automations page', () => { }, ]; - render(); + render(); expect( screen.getByText("Put your team's work on autopilot with automations"), ).toBeInTheDocument(); @@ -215,7 +219,7 @@ it('prioritizes communication accounts before source-control accounts', () => { }, ]; - render(); + render(); expect(screen.getByText('Link your Slack account')).toBeInTheDocument(); dismissCard(); expect(screen.getByText('Link your Discord account')).toBeInTheDocument(); @@ -234,7 +238,7 @@ it('does not offer Slack installation, but links an installed Slack account', () }, ]; - render(); + render(); expect( screen.queryByText(/Chat with Roomote on Slack/), ).not.toBeInTheDocument(); @@ -244,20 +248,9 @@ it('does not offer Slack installation, but links an installed Slack account', () }); it('uses the requested admin integration setup order', () => { - render(); - - for (const name of [ - 'Notion', - 'Sentry', - 'Linear', - 'Jira', - 'monday.com', - 'Vercel', - 'Supabase', - 'PostHog', - 'Grafana', - 'Asana', - ]) { + render(); + + for (const name of ['Notion', 'Linear', 'Jira', 'Sentry', 'Grafana']) { expect( screen.getByText(`Enable ${name} for your workspace`), ).toBeInTheDocument(); @@ -266,7 +259,7 @@ it('uses the requested admin integration setup order', () => { }); it('opens the highlighted integration settings for admin setup', () => { - render(); + render(); fireEvent.click(screen.getByRole('button', { name: 'Set it up' })); expect(mockPush).toHaveBeenCalledWith( @@ -278,7 +271,7 @@ it('does not show workspace setup to non-admins and prompts enabled personal MCP isAdmin = false; enabledMcpIds = ['notion']; - render(); + render(); expect(screen.getByText('Link your Notion account')).toBeInTheDocument(); expect( screen.queryByText('Enable Notion for your workspace'), @@ -295,7 +288,7 @@ it('starts Slack linking directly', () => { linked: false, }, ]; - render(); + render(); fireEvent.click(screen.getByRole('button', { name: 'Link' })); expect(mockAuthenticateSlack).toHaveBeenCalledWith('/', expect.any(Object)); @@ -311,8 +304,103 @@ it('opens the Discord account-link dialog directly', () => { linked: false, }, ]; - render(); + render(); fireEvent.click(screen.getByRole('button', { name: 'Link' })); expect(screen.getByText('Discord link flow')).toBeInTheDocument(); }); + +it('shows an ambient card alongside the task card', () => { + render(); + + expect( + screen.getByText('Enable Notion for your workspace'), + ).toBeInTheDocument(); + expect( + screen.getByText('Explore recipes for common workflows'), + ).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Explore' })).toHaveAttribute( + 'href', + 'https://docs.roomote.dev/cookbook', + ); + expect(screen.getByRole('link', { name: 'Explore' })).toHaveAttribute( + 'target', + '_blank', + ); +}); + +it('advances ambient cards independently from task cards', () => { + render(); + + const dismissButtons = screen.getAllByRole('button', { name: 'Dismiss' }); + fireEvent.click(dismissButtons[2]!); + + expect( + screen.getByText('Enable Notion for your workspace'), + ).toBeInTheDocument(); + expect( + screen.getByText('Questions or ideas? We would love to hear from you'), + ).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Email us' })).toHaveAttribute( + 'href', + 'mailto:help@roomote.dev', + ); +}); + +it('shows an all-set state when a lane is exhausted', () => { + render(); + + for (let index = 0; index < 5; index += 1) { + dismissCard(); + } + + expect(screen.getByText("You're all set")).toBeInTheDocument(); + expect( + screen.getByText('Explore recipes for common workflows'), + ).toBeInTheDocument(); +}); + +it('persists the collapsed pane state', () => { + const { unmount } = render(); + + fireEvent.click(screen.getByRole('button', { name: 'Collapse onboarding' })); + expect( + screen.queryByText('Enable Notion for your workspace'), + ).not.toBeInTheDocument(); + expect(localStorage.getItem('home-onboarding-pane-collapsed')).toBe('true'); + + unmount(); + render(); + + expect( + screen.getByRole('button', { name: 'Expand onboarding' }), + ).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Expand onboarding' })); + expect( + screen.getByText('Enable Notion for your workspace'), + ).toBeInTheDocument(); + expect(localStorage.getItem('home-onboarding-pane-collapsed')).toBe('false'); +}); + +it('omits lower-priority integrations from onboarding', () => { + render(); + + for (const name of ['monday.com', 'Vercel', 'Supabase', 'PostHog', 'Asana']) { + expect( + screen.queryByText(`Enable ${name} for your workspace`), + ).not.toBeInTheDocument(); + } +}); + +it('does not show an all-set task state while priorities are loading', () => { + isAdmin = false; + onboardingPending = true; + automationsPending = true; + + render(); + + expect(screen.queryByText("You're all set")).not.toBeInTheDocument(); + expect( + screen.getByText('Explore recipes for common workflows'), + ).toBeInTheDocument(); +}); diff --git a/apps/web/src/app/(authenticated)/home/OnboardingCard.tsx b/apps/web/src/app/(authenticated)/home/OnboardingPane.tsx similarity index 71% rename from apps/web/src/app/(authenticated)/home/OnboardingCard.tsx rename to apps/web/src/app/(authenticated)/home/OnboardingPane.tsx index e3cc93134..da49adb7f 100644 --- a/apps/web/src/app/(authenticated)/home/OnboardingCard.tsx +++ b/apps/web/src/app/(authenticated)/home/OnboardingPane.tsx @@ -27,7 +27,11 @@ import { useTRPC } from '@/trpc/client'; import { SETTINGS_PATHS } from '@/lib/settings'; import { + BookMarked, BrandIcon, + Check, + ChevronDown, + ChevronUp, Dialog, DialogContent, DialogDescription, @@ -35,37 +39,31 @@ import { DialogTitle, Github, LinearLogo, + Mail, + Skeleton, Slack, X, Button, - Spinner, Zap, } from '@/components/system'; import { McpIcon } from '@/components/settings/McpIcon'; import { DiscordLinkAccountStep } from '@/components/settings/DiscordLinkAccountStep'; import { TelegramLinkAccountStep } from '@/components/settings/TelegramLinkAccountStep'; +import { DOCS_COOKBOOK_URL } from '@/lib/docs'; const DISMISSED_KEY = 'OnboardingCardsDismissedByOrg'; const DISMISSED_DEPLOYMENT_KEY = 'deployment'; +const COLLAPSED_KEY = 'home-onboarding-pane-collapsed'; const ADMIN_INTEGRATION_ORDER = [ 'notion', - 'sentry', 'linear', 'jira', - 'monday', - 'vercel', - 'supabase', - 'posthog', + 'sentry', 'grafana', - 'asana', ] as const; -const PERSONAL_MCP_INTEGRATION_ORDER = [ - 'notion', - 'monday', - 'supabase', -] as const; +const PERSONAL_MCP_INTEGRATION_ORDER = ['notion'] as const; const CARD_EXIT_TRANSITION = { duration: 0.4, @@ -104,7 +102,9 @@ type CardConfig = { icon: ReactNode; label: string; buttonLabel: string; - onClick: () => void; + onClick?: () => void; + href?: string; + external?: boolean; disabled?: boolean; dismissible?: boolean; visible: boolean; @@ -147,20 +147,151 @@ function writeDismissedCardIds(ids: string[]): void { } } +function readCollapsed(): boolean { + try { + return localStorage.getItem(COLLAPSED_KEY) === 'true'; + } catch { + return false; + } +} + +function writeCollapsed(collapsed: boolean): void { + try { + localStorage.setItem(COLLAPSED_KEY, String(collapsed)); + } catch { + // Ignore localStorage failures. + } +} + function getMcpIntegration(id: string) { const integration = MCP_INTEGRATIONS.find((entry) => entry.id === id); if (!integration) throw new Error(`Unknown MCP integration: ${id}`); return integration; } -/** Shows at most one onboarding guidance card at a time, in priority order. */ -export function OnboardingCard() { +function OnboardingCardRow({ + card, + onDismiss, +}: { + card: CardConfig; + onDismiss: () => void; +}) { + const action = card.href ? ( + + ) : ( + + ); + + return ( + + {card.icon} +
+ + + {card.label} + + {card.dismissible !== false ? ( + + ) : null} + + {action} + {card.dismissible !== false ? ( + + ) : null} +
+
+ ); +} + +function OnboardingLane({ + cards, + dismissed, + onDismiss, + pending = false, +}: { + cards: CardConfig[]; + dismissed: Record; + onDismiss: (cardId: string) => void; + pending?: boolean; +}) { + const activeCard = cards.find((card) => card.visible && !dismissed[card.id]); + + return ( +
+ + {pending ? ( + + ) : activeCard ? ( + onDismiss(activeCard.id)} + /> + ) : ( + + + You're all set + + )} + +
+ ); +} + +/** Shows the highest-priority task and ambient onboarding guidance together. */ +export function OnboardingPane() { const { isAdmin } = useAuthorizedUser(); const searchParams = useSearchParams(); const router = useRouter(); const trpc = useTRPC(); - const shouldShowSuggestedTasksCard = - searchParams.get('link_suggested') === 'true'; const onboarding = useQuery(trpc.onboarding.status.queryOptions()); const enablements = useDeploymentMcpEnablements(); const userMcpConnections = useUserMcpConnections(); @@ -181,6 +312,7 @@ export function OnboardingCard() { null, ); const [dismissed, setDismissed] = useState>({}); + const [collapsed, setCollapsed] = useState(false); const connectionToastedRef = useRef(false); useEffect(() => { @@ -207,14 +339,28 @@ export function OnboardingCard() { ); }, []); + useEffect(() => { + setCollapsed(readCollapsed()); + }, []); + const dismiss = (cardId: string) => { - const nextDismissed = { ...dismissed, [cardId]: true }; - setDismissed(nextDismissed); - writeDismissedCardIds( - Object.entries(nextDismissed) - .filter(([, isDismissed]) => isDismissed) - .map(([id]) => id), - ); + setDismissed((currentDismissed) => { + const nextDismissed = { ...currentDismissed, [cardId]: true }; + writeDismissedCardIds( + Object.entries(nextDismissed) + .filter(([, isDismissed]) => isDismissed) + .map(([id]) => id), + ); + return nextDismissed; + }); + }; + + const toggleCollapsed = () => { + setCollapsed((currentCollapsed) => { + const nextCollapsed = !currentCollapsed; + writeCollapsed(nextCollapsed); + return nextCollapsed; + }); }; const startOAuthLink = ( @@ -428,7 +574,7 @@ export function OnboardingCard() { !status?.userHasLinkedLinear, }; - const cards: CardConfig[] = [ + const taskCards: CardConfig[] = [ { id: 'automations', icon: ( @@ -445,15 +591,6 @@ export function OnboardingCard() { Boolean(automationOnboardingStatus) && !automationOnboardingStatus?.hasEnabledAutomations, }, - { - id: 'link-suggested-tasks', - icon: , - label: 'Your selected tasks have started.', - buttonLabel: 'Check their progress', - onClick: () => router.push('/tasks'), - dismissible: false, - visible: shouldShowSuggestedTasksCard, - }, ...COMMUNICATION_PROVIDER_ORDER.flatMap((providerId) => providerCards.filter((card) => card.id === `link-${providerId}`), ), @@ -465,64 +602,54 @@ export function OnboardingCard() { linearPersonalCard, ]; - const activeCard = cards.find((card) => card.visible && !dismissed[card.id]); - if (!activeCard) return null; + const ambientCards: CardConfig[] = [ + { + id: 'cookbook', + icon: , + label: 'Explore recipes for common workflows', + buttonLabel: 'Explore', + href: DOCS_COOKBOOK_URL, + external: true, + visible: true, + }, + { + id: 'talk-to-us', + icon: , + label: 'Questions or ideas? We would love to hear from you', + buttonLabel: 'Email us', + href: 'mailto:help@roomote.dev', + visible: true, + }, + ]; return ( <> -
- - - {activeCard.icon} -
- - - {activeCard.label} - - {activeCard.dismissible !== false && ( - - )} - - - {activeCard.dismissible !== false && ( - - )} -
-
-
+
+ {collapsed ? null : ( +
+ + +
+ )} +
task.suggestionId !== null, - ); - return (
@@ -511,7 +507,6 @@ export function SetupSignedInFlow() { : [] } includeLinear={status.hasLinear} - linkSuggestedTasks={hasPersistedSelectedSuggestedTasks} computeProvisioning={computeProvisioning} onRetryComputeProvisioning={() => goToStep('compute-config', { revisit: true }) diff --git a/apps/web/src/app/(onboarding)/setup/StepInvoke.client.test.tsx b/apps/web/src/app/(onboarding)/setup/StepInvoke.client.test.tsx index d6c103932..cc714d4df 100644 --- a/apps/web/src/app/(onboarding)/setup/StepInvoke.client.test.tsx +++ b/apps/web/src/app/(onboarding)/setup/StepInvoke.client.test.tsx @@ -467,16 +467,4 @@ describe('Setup StepInvoke', () => { screen.getByText('Try: @roomote Add support for a reset password flow.'), ).toBeInTheDocument(); }); - - it('includes the link_suggested param when selected suggested tasks were started', async () => { - render(); - - fireEvent.click(screen.getByRole('button', { name: /let'?s go/i })); - - await waitFor(() => { - expect(replaceMock).toHaveBeenCalledWith( - '/?environmentId=env-1&link_suggested=true', - ); - }); - }); }); diff --git a/apps/web/src/app/(onboarding)/setup/StepInvoke.tsx b/apps/web/src/app/(onboarding)/setup/StepInvoke.tsx index e9e90d69f..1f83883c4 100644 --- a/apps/web/src/app/(onboarding)/setup/StepInvoke.tsx +++ b/apps/web/src/app/(onboarding)/setup/StepInvoke.tsx @@ -31,7 +31,6 @@ type CommunicationProviderId = 'slack' | 'microsoft' | 'telegram' | 'discord'; export function StepInvoke({ onTryItOut, onboardingTaskId, - linkSuggestedTasks = false, communicationProviders = [], sourceControlProviders = [], includeLinear = false, @@ -40,7 +39,6 @@ export function StepInvoke({ }: { onTryItOut?: () => void; onboardingTaskId?: string | null; - linkSuggestedTasks?: boolean; communicationProviders?: readonly CommunicationProviderId[]; sourceControlProviders?: readonly SourceControlProvider[]; includeLinear?: boolean; @@ -152,10 +150,6 @@ export function StepInvoke({ params.set('environmentId', targetEnv.id); } - if (linkSuggestedTasks) { - params.set('link_suggested', 'true'); - } - const query = params.toString(); router.replace(query ? `/?${query}` : '/'); },