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: () =>
@@ -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 : (
+
+
+
+
+ )}
+