Cloud Security Tests
diff --git a/apps/app/src/app/(app)/[orgId]/components/ConditionalPaddingWrapper.tsx b/apps/app/src/app/(app)/[orgId]/components/ConditionalPaddingWrapper.tsx
new file mode 100644
index 000000000..8ef7470af
--- /dev/null
+++ b/apps/app/src/app/(app)/[orgId]/components/ConditionalPaddingWrapper.tsx
@@ -0,0 +1,18 @@
+'use client';
+
+import { usePathname } from 'next/navigation';
+
+export function ConditionalPaddingWrapper({ children }: { children: React.ReactNode }) {
+ const pathname = usePathname();
+
+ // Don't add padding for automation pages, automation overview pages, and task detail pages
+ const isAutomationPage = pathname?.includes('/automation/');
+ const isAutomationOverviewPage = pathname?.includes('/automations/');
+ const isTaskDetailPage = pathname?.match(/\/tasks\/[^/]+$/) !== null; // Matches /tasks/[taskId] but not /tasks/[taskId]/automation
+
+ if (isAutomationPage || isAutomationOverviewPage || isTaskDetailPage) {
+ return <>{children}>;
+ }
+
+ return
{children}
;
+}
diff --git a/apps/app/src/app/(app)/[orgId]/components/OnboardingTracker.tsx b/apps/app/src/app/(app)/[orgId]/components/OnboardingTracker.tsx
index 28fdcc4f3..f69d86960 100644
--- a/apps/app/src/app/(app)/[orgId]/components/OnboardingTracker.tsx
+++ b/apps/app/src/app/(app)/[orgId]/components/OnboardingTracker.tsx
@@ -1,19 +1,18 @@
'use client';
-import { LogoSpinner } from '@/components/logo-spinner';
-import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@comp/ui/card';
+import { Card, CardContent } from '@comp/ui/card';
import type { Onboarding } from '@db';
import { useRealtimeRun } from '@trigger.dev/react-hooks';
import { AnimatePresence, motion } from 'framer-motion';
-import { AlertTriangle, Rocket, ShieldAlert, Zap } from 'lucide-react';
-import { useEffect, useState } from 'react';
-
-const PROGRESS_MESSAGES = [
- 'Learning about your company...',
- 'Creating Risks...',
- 'Creating Vendors...',
- 'Tailoring Policies...',
-];
+import { AlertTriangle, CheckCircle2, ChevronDown, ChevronUp, ChevronsDown, ChevronsUp, Loader2, Rocket, Settings, ShieldAlert, X, Zap } from 'lucide-react';
+import { createPortal } from 'react-dom';
+import { useEffect, useMemo, useState } from 'react';
+
+const ONBOARDING_STEPS = [
+ { key: 'vendors', label: 'Researching Vendors', order: 1 },
+ { key: 'risk', label: 'Creating Risks', order: 2 },
+ { key: 'policies', label: 'Tailoring Policies', order: 3 },
+] as const;
const IN_PROGRESS_STATUSES = [
'QUEUED',
@@ -33,65 +32,244 @@ const getFriendlyStatusName = (status: string): string => {
};
export const OnboardingTracker = ({ onboarding }: { onboarding: Onboarding }) => {
- const [currentMessageIndex, setCurrentMessageIndex] = useState(0);
const triggerJobId = onboarding.triggerJobId;
+ const [mounted, setMounted] = useState(false);
+ const [isMinimized, setIsMinimized] = useState(false);
+ const [isDismissed, setIsDismissed] = useState(false);
+ const [isPoliciesExpanded, setIsPoliciesExpanded] = useState(false);
+ const [isVendorsExpanded, setIsVendorsExpanded] = useState(false);
+ const [isRisksExpanded, setIsRisksExpanded] = useState(false);
// useRealtimeRun will automatically get the token from TriggerProvider context
+ // This gives us real-time updates including metadata changes
const { run, error } = useRealtimeRun(triggerJobId || '', {
enabled: !!triggerJobId,
});
useEffect(() => {
- if (!triggerJobId) return;
-
- let interval: NodeJS.Timeout;
- if (run && IN_PROGRESS_STATUSES.includes(run.status)) {
- interval = setInterval(() => {
- setCurrentMessageIndex((prevIndex) => (prevIndex + 1) % PROGRESS_MESSAGES.length);
- }, 4000);
- } else {
- setCurrentMessageIndex(0); // Reset when not in progress
+ setMounted(true);
+ }, []);
+
+ // Auto-minimize when completed
+ useEffect(() => {
+ if (run?.status === 'COMPLETED' && !isMinimized) {
+ setIsMinimized(true);
+ }
+ }, [run?.status, isMinimized]);
+
+ // Extract step completion from metadata (real-time updates)
+ const stepStatus = useMemo(() => {
+ if (!run?.metadata) {
+ return {
+ vendors: false,
+ risk: false,
+ policies: false,
+ currentStep: null,
+ vendorsTotal: 0,
+ vendorsCompleted: 0,
+ vendorsRemaining: 0,
+ vendorsInfo: [],
+ vendorsStatus: {},
+ risksTotal: 0,
+ risksCompleted: 0,
+ risksRemaining: 0,
+ risksInfo: [],
+ risksStatus: {},
+ policiesTotal: 0,
+ policiesCompleted: 0,
+ policiesRemaining: 0,
+ policiesInfo: [],
+ policiesStatus: {},
+ };
+ }
+
+ const meta = run.metadata as Record
;
+
+ // Build vendorsStatus object from individual vendor status keys
+ const vendorsStatus: Record = {};
+ const vendorsInfo = (meta.vendorsInfo as Array<{ id: string; name: string }>) || [];
+
+ vendorsInfo.forEach((vendor) => {
+ const statusKey = `vendor_${vendor.id}_status`;
+ vendorsStatus[vendor.id] = (meta[statusKey] as 'pending' | 'processing' | 'completed') || 'pending';
+ });
+
+ // Build risksStatus object from individual risk status keys
+ const risksStatus: Record = {};
+ const risksInfo = (meta.risksInfo as Array<{ id: string; name: string }>) || [];
+
+ risksInfo.forEach((risk) => {
+ const statusKey = `risk_${risk.id}_status`;
+ risksStatus[risk.id] = (meta[statusKey] as 'pending' | 'processing' | 'completed') || 'pending';
+ });
+
+ // Build policiesStatus object from individual policy status keys
+ const policiesStatus: Record = {};
+ const policiesInfo = (meta.policiesInfo as Array<{ id: string; name: string }>) || [];
+
+ policiesInfo.forEach((policy) => {
+ // Check for individual policy status key: policy_{id}_status
+ const statusKey = `policy_${policy.id}_status`;
+ policiesStatus[policy.id] = (meta[statusKey] as 'pending' | 'processing' | 'completed') || 'pending';
+ });
+
+ return {
+ vendors: meta.vendors === true,
+ risk: meta.risk === true,
+ policies: meta.policies === true,
+ currentStep: (meta.currentStep as string) || null,
+ vendorsTotal: (meta.vendorsTotal as number) || 0,
+ vendorsCompleted: (meta.vendorsCompleted as number) || 0,
+ vendorsRemaining: (meta.vendorsRemaining as number) || 0,
+ vendorsInfo,
+ vendorsStatus,
+ risksTotal: (meta.risksTotal as number) || 0,
+ risksCompleted: (meta.risksCompleted as number) || 0,
+ risksRemaining: (meta.risksRemaining as number) || 0,
+ risksInfo,
+ risksStatus,
+ policiesTotal: (meta.policiesTotal as number) || 0,
+ policiesCompleted: (meta.policiesCompleted as number) || 0,
+ policiesRemaining: (meta.policiesRemaining as number) || 0,
+ policiesInfo,
+ policiesStatus,
+ };
+ }, [run?.metadata]);
+
+ // Calculate current step from metadata
+ const currentStep = useMemo(() => {
+ if (stepStatus.currentStep) {
+ // Use the currentStep from metadata if available
+ const step = ONBOARDING_STEPS.find((s) => stepStatus.currentStep?.includes(s.label));
+ return step || null;
+ }
+ // Otherwise find first incomplete step
+ return ONBOARDING_STEPS.find((step) => !stepStatus[step.key as keyof typeof stepStatus]);
+ }, [stepStatus]);
+
+ // Auto-expand current step and collapse others
+ useEffect(() => {
+ if (!currentStep) return;
+
+ const stepKey = currentStep.key;
+
+ // Expand current step if it has items to show
+ if (stepKey === 'vendors' && stepStatus.vendorsTotal > 0) {
+ setIsVendorsExpanded(true);
+ setIsRisksExpanded(false);
+ setIsPoliciesExpanded(false);
+ } else if (stepKey === 'risk' && stepStatus.risksTotal > 0) {
+ setIsVendorsExpanded(false);
+ setIsRisksExpanded(true);
+ setIsPoliciesExpanded(false);
+ } else if (stepKey === 'policies' && stepStatus.policiesTotal > 0) {
+ setIsVendorsExpanded(false);
+ setIsRisksExpanded(false);
+ setIsPoliciesExpanded(true);
}
- return () => clearInterval(interval);
- }, [run, triggerJobId]);
+ }, [currentStep?.key, stepStatus.vendorsTotal, stepStatus.risksTotal, stepStatus.policiesTotal]);
+
+ // Build dynamic current step message with progress
+ const currentStepMessage = useMemo(() => {
+ if (stepStatus.currentStep) {
+ // If it's the policies step, update the count dynamically
+ if (stepStatus.currentStep.includes('Tailoring Policies')) {
+ if (stepStatus.policiesTotal > 0) {
+ return `Tailoring Policies... (${stepStatus.policiesCompleted}/${stepStatus.policiesTotal})`;
+ }
+ return 'Tailoring Policies...';
+ }
+ return stepStatus.currentStep;
+ }
+ if (currentStep) {
+ return currentStep.label;
+ }
+ return 'Initializing...';
+ }, [stepStatus.currentStep, stepStatus.policiesTotal, stepStatus.policiesCompleted, currentStep]);
+
+ if (!triggerJobId || !mounted) {
+ return null;
+ }
- if (!triggerJobId) {
- return Unable to load onboarding tracker.
;
+ // Dismiss completed card
+ if (run?.status === 'COMPLETED' && isDismissed) {
+ return null;
}
- if (!triggerJobId) {
- return (
-
-
- Onboarding Status
-
- Organization setup has not started yet.
-
-
-
-
-
{/* Use theme warning color */}
-
-
Awaiting Initiation
-
- No onboarding process has been started.
-
-
-
-
-
+ // Minimized view - show only current step
+ if (isMinimized) {
+ const isCompleted = run?.status === 'COMPLETED';
+
+ return createPortal(
+
+
+
+
+
+
+ {isCompleted ? (
+
+ ) : (
+
+ )}
+
+
+ {isCompleted ? 'Setup Complete' : 'Setting up your organization'}
+
+ {!isCompleted && currentStepMessage && (
+
+ {currentStepMessage}
+
+ )}
+ {isCompleted && (
+
+ Your organization is ready!
+
+ )}
+
+
+
+ {isCompleted && (
+
+ )}
+
+
+
+
+
+
+ ,
+ document.body,
);
}
const renderStatusContent = () => {
if (!run && !error) {
return (
-
-
-
-
Initializing Status
-
- Checking the current onboarding status...
+
+
+
+
Initializing...
+
+ Checking onboarding status
@@ -99,15 +277,21 @@ export const OnboardingTracker = ({ onboarding }: { onboarding: Onboarding }) =>
}
if (!run) {
return (
-
-
{/* Use theme warning color */}
-
-
Status Unavailable
{' '}
- {/* Use theme warning color */}
-
- Could not retrieve current onboarding status.
+
+
+
+
Status Unavailable
+
+ Could not retrieve status
+
);
}
@@ -122,34 +306,354 @@ export const OnboardingTracker = ({ onboarding }: { onboarding: Onboarding }) =>
case 'DEQUEUED':
case 'DELAYED':
return (
-
-
-
-
-
- {PROGRESS_MESSAGES[currentMessageIndex]}
-
-
-
- We are setting up your organization. This may take a few moments.
-
+
+ {/* Header */}
+
+
+
+
+ Setting up your organization
+
+
+
+
+
+ {/* Step progress - scrollable */}
+
+ {ONBOARDING_STEPS.map((step) => {
+ const isCompleted = stepStatus[step.key as keyof typeof stepStatus];
+ const isCurrent = currentStep?.key === step.key;
+ const isVendorsStep = step.key === 'vendors';
+ const isRisksStep = step.key === 'risk';
+ const isPoliciesStep = step.key === 'policies';
+
+ // Vendors step with expandable dropdown
+ if (isVendorsStep && stepStatus.vendorsTotal > 0) {
+ return (
+
+
+
+ {/* Expanded vendor list */}
+ {isVendorsExpanded && stepStatus.vendorsInfo.length > 0 && (
+
+
+ {stepStatus.vendorsInfo.map((vendor) => {
+ const vendorStatus = stepStatus.vendorsStatus[vendor.id] || 'pending';
+ const isVendorCompleted = vendorStatus === 'completed';
+ const isVendorProcessing = vendorStatus === 'processing';
+
+ return (
+
+ {isVendorCompleted ? (
+
+ ) : isVendorProcessing ? (
+
+ ) : (
+
+ )}
+
+ {vendor.name}
+
+
+ );
+ })}
+
+
+ )}
+
+ );
+ }
+
+ // Risks step with expandable dropdown
+ if (isRisksStep && stepStatus.risksTotal > 0) {
+ return (
+
+
+
+ {/* Expanded risk list */}
+ {isRisksExpanded && stepStatus.risksInfo.length > 0 && (
+
+
+ {stepStatus.risksInfo.map((risk) => {
+ const riskStatus = stepStatus.risksStatus[risk.id] || 'pending';
+ const isRiskCompleted = riskStatus === 'completed';
+ const isRiskProcessing = riskStatus === 'processing';
+
+ return (
+
+ {isRiskCompleted ? (
+
+ ) : isRiskProcessing ? (
+
+ ) : (
+
+ )}
+
+ {risk.name}
+
+
+ );
+ })}
+
+
+ )}
+
+ );
+ }
+
+ if (isPoliciesStep && stepStatus.policiesTotal > 0) {
+ // Policies step with expandable dropdown
+ return (
+
+
+
+ {/* Expanded policy list */}
+ {isPoliciesExpanded && stepStatus.policiesInfo.length > 0 && (
+
+
+ {stepStatus.policiesInfo.map((policy) => {
+ const policyStatus = stepStatus.policiesStatus[policy.id] || 'pending';
+ const isPolicyCompleted = policyStatus === 'completed';
+ const isPolicyProcessing = policyStatus === 'processing';
+
+ return (
+
+ {isPolicyCompleted ? (
+
+ ) : isPolicyProcessing ? (
+
+ ) : (
+
+ )}
+
+ {policy.name}
+
+
+ );
+ })}
+
+
+ )}
+
+ );
+ }
+
+ // Regular step
+ return (
+
+ {isCompleted ? (
+
+ ) : isCurrent ? (
+
+ ) : (
+
+ )}
+
+ {step.label}
+
+
+ );
+ })}
);
case 'COMPLETED':
return (
-
-
-
-
Setup Complete
-
Your organization is ready.
+
+ {/* Header */}
+
+
+
+
+
+
+
+
Your organization is ready!
+
+ All onboarding steps have been completed successfully.
+
+
+
+
+ {/* Show completed steps */}
+
+ {ONBOARDING_STEPS.map((step) => (
+
+
+
+ {step.label}
+
+
+ ))}
);
@@ -161,16 +665,23 @@ export const OnboardingTracker = ({ onboarding }: { onboarding: Onboarding }) =>
case 'TIMED_OUT': {
const errorMessage = run.error?.message || 'An unexpected issue occurred.';
const truncatedMessage =
- errorMessage.length > 100 ? `${errorMessage.substring(0, 97)}...` : errorMessage;
+ errorMessage.length > 60 ? `${errorMessage.substring(0, 57)}...` : errorMessage;
return (
-
-
{' '}
-
+
+
+
Setup {friendlyStatus}
-
{truncatedMessage}
+
{truncatedMessage}
+
);
}
@@ -178,32 +689,43 @@ export const OnboardingTracker = ({ onboarding }: { onboarding: Onboarding }) =>
const exhaustiveCheck: never = run.status as never;
return (
-
-
-
+
+
+
Unknown Status
-
- Received an unhandled status: {exhaustiveCheck}
+
+ Status: {exhaustiveCheck}
+
);
}
}
};
- if (run?.status === 'COMPLETED') {
- return null;
- }
-
- return (
-
-
- {renderStatusContent()}
-
-
+
+
+
+ {renderStatusContent()}
+
+
+
+ ,
+ document.body,
);
};
diff --git a/apps/app/src/app/(app)/[orgId]/integrations/actions/get-relevant-tasks.ts b/apps/app/src/app/(app)/[orgId]/integrations/actions/get-relevant-tasks.ts
new file mode 100644
index 000000000..14b67aee1
--- /dev/null
+++ b/apps/app/src/app/(app)/[orgId]/integrations/actions/get-relevant-tasks.ts
@@ -0,0 +1,70 @@
+'use server';
+
+import { openai } from '@ai-sdk/openai';
+import { generateObject } from 'ai';
+import { z } from 'zod';
+
+const RelevantTasksSchema = z.object({
+ relevantTasks: z.array(
+ z.object({
+ taskTemplateId: z.string(),
+ taskName: z.string(),
+ reason: z.string(),
+ prompt: z.string(),
+ }),
+ ),
+});
+
+export async function getRelevantTasksForIntegration(
+ integrationName: string,
+ integrationDescription: string,
+ taskTemplates: Array<{ id: string; name: string; description: string }>,
+): Promise<{ taskTemplateId: string; taskName: string; reason: string; prompt: string }[]> {
+ if (taskTemplates.length === 0) {
+ return [];
+ }
+
+ // Format task templates for the prompt (truncate descriptions to reduce token usage)
+ const tasksList = taskTemplates
+ .map((task) => {
+ // Truncate description to max 100 chars to reduce token usage
+ const truncatedDesc =
+ task.description.length > 100
+ ? task.description.substring(0, 100) + '...'
+ : task.description;
+ return `${task.id}|${task.name}|${truncatedDesc}`;
+ })
+ .join('\n');
+
+ const systemPrompt = `GRC expert. Find tasks relevant to integration. Return JSON with: taskTemplateId, taskName, reason (1 sentence), prompt (actionable, ready to use). Be selective.`;
+
+ const userPrompt = `Integration: ${integrationName} - ${integrationDescription}
+
+Tasks (format: ID|Name|Description):
+${tasksList}
+
+Return only clearly relevant tasks.`;
+
+ const promptSize = (systemPrompt + userPrompt).length;
+ console.log(`[getRelevantTasks] Prompt size: ${promptSize} chars, ${taskTemplates.length} tasks`);
+
+ try {
+ const startTime = Date.now();
+ const { object, usage } = await generateObject({
+ model: openai('gpt-4.1-mini'),
+ schema: RelevantTasksSchema,
+ system: systemPrompt,
+ prompt: userPrompt,
+ });
+ const duration = Date.now() - startTime;
+
+ console.log(
+ `[getRelevantTasks] Generated ${object.relevantTasks.length} tasks in ${duration}ms (tokens: ${usage?.totalTokens || 'unknown'})`,
+ );
+
+ return object.relevantTasks;
+ } catch (error) {
+ console.error('Error generating relevant tasks:', error);
+ return [];
+ }
+}
diff --git a/apps/app/src/app/(app)/[orgId]/integrations/components/IntegrationsGrid.tsx b/apps/app/src/app/(app)/[orgId]/integrations/components/IntegrationsGrid.tsx
index c73fae35a..eb9cb771d 100644
--- a/apps/app/src/app/(app)/[orgId]/integrations/components/IntegrationsGrid.tsx
+++ b/apps/app/src/app/(app)/[orgId]/integrations/components/IntegrationsGrid.tsx
@@ -1,5 +1,6 @@
'use client';
+import { api } from '@/lib/api-client';
import { Badge } from '@comp/ui/badge';
import { Button } from '@comp/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@comp/ui/card';
@@ -10,12 +11,14 @@ import {
DialogHeader,
DialogTitle,
} from '@comp/ui/dialog';
-import { ArrowRight, Sparkles } from 'lucide-react';
+import { Skeleton } from '@comp/ui/skeleton';
+import { ArrowRight, CheckCircle2, Loader2, Plug, Sparkles, Zap } from 'lucide-react';
import Image from 'next/image';
import Link from 'next/link';
-import { useParams } from 'next/navigation';
-import { useMemo, useState } from 'react';
+import { useParams, useRouter } from 'next/navigation';
+import { useEffect, useMemo, useState } from 'react';
import { toast } from 'sonner';
+import { getRelevantTasksForIntegration } from '../actions/get-relevant-tasks';
import {
CATEGORIES,
INTEGRATIONS,
@@ -26,11 +29,113 @@ import { SearchInput } from './SearchInput';
const LOGO_TOKEN = 'pk_AZatYxV5QDSfWpRDaBxzRQ';
-export function IntegrationsGrid() {
+interface RelevantTask {
+ taskTemplateId: string;
+ taskName: string;
+ reason: string;
+ prompt: string;
+}
+
+function TaskCard({ task, orgId }: { task: RelevantTask; orgId: string }) {
+ const [isNavigating, setIsNavigating] = useState(false);
+ const router = useRouter();
+
+ const handleCardClick = async () => {
+ setIsNavigating(true);
+ toast.loading('Opening task automation...', { id: 'navigating' });
+
+ try {
+ // Fetch all tasks and find one with matching template ID
+ const response = await api.get
>(
+ '/v1/tasks',
+ orgId,
+ );
+
+ if (response.error || !response.data) {
+ throw new Error(response.error || 'Failed to fetch tasks');
+ }
+
+ // Debug logging
+ console.log('Looking for taskTemplateId:', task.taskTemplateId);
+ console.log(
+ 'Available tasks:',
+ response.data.map((t) => ({ id: t.id, taskTemplateId: t.taskTemplateId })),
+ );
+
+ const matchingTask = response.data.find(
+ (t) => t.taskTemplateId && t.taskTemplateId === task.taskTemplateId,
+ );
+
+ if (!matchingTask) {
+ toast.dismiss('navigating');
+ toast.error(`Task "${task.taskName}" not found. Please create it first.`);
+ setIsNavigating(false);
+ await router.push(`/${orgId}/tasks`);
+ return;
+ }
+
+ const url = `/${orgId}/tasks/${matchingTask.id}/automation/new?prompt=${encodeURIComponent(task.prompt)}`;
+ toast.dismiss('navigating');
+ toast.success('Redirecting...', { duration: 1000 });
+
+ // Use window.location for immediate navigation
+ window.location.href = url;
+ } catch (error) {
+ console.error('Error finding task:', error);
+ toast.dismiss('navigating');
+ toast.error('Failed to find task');
+ setIsNavigating(false);
+ }
+ };
+
+ return (
+
+ {isNavigating && (
+
+
+
+
Opening task...
+
+ Redirecting to automation with prompt pre-filled
+
+
+
+ )}
+
+
+
+
+
+
+
+ {task.taskName}
+
+
+
+ {task.reason}
+
+
+
+
+
+
+ );
+}
+
+export function IntegrationsGrid({
+ taskTemplates,
+}: {
+ taskTemplates: Array<{ id: string; name: string; description: string }>;
+}) {
const { orgId } = useParams<{ orgId: string }>();
const [searchQuery, setSearchQuery] = useState('');
const [selectedCategory, setSelectedCategory] = useState('All');
const [selectedIntegration, setSelectedIntegration] = useState(null);
+ const [relevantTasks, setRelevantTasks] = useState([]);
+ const [isLoadingTasks, setIsLoadingTasks] = useState(false);
// Filter integrations with fuzzy search
const filteredIntegrations = useMemo(() => {
@@ -62,24 +167,48 @@ export function IntegrationsGrid() {
toast.success('Prompt copied to clipboard!');
};
+ useEffect(() => {
+ if (selectedIntegration && orgId && taskTemplates.length > 0) {
+ setIsLoadingTasks(true);
+ getRelevantTasksForIntegration(
+ selectedIntegration.name,
+ selectedIntegration.description,
+ taskTemplates,
+ )
+ .then((tasks) => {
+ setRelevantTasks(tasks);
+ })
+ .catch((error) => {
+ console.error('Error fetching relevant tasks:', error);
+ setRelevantTasks([]);
+ })
+ .finally(() => {
+ setIsLoadingTasks(false);
+ });
+ } else {
+ setRelevantTasks([]);
+ }
+ }, [selectedIntegration, orgId, taskTemplates]);
+
return (
{/* Search and Filters */}
-
+
{/* Search Bar */}
- {/* Category Filters */}
-
+ {/* Category Filters - Horizontal scroll on mobile */}
+
@@ -89,6 +218,7 @@ export function IntegrationsGrid() {
size="sm"
variant={selectedCategory === category ? 'default' : 'outline'}
onClick={() => setSelectedCategory(category)}
+ className="flex-shrink-0 whitespace-nowrap min-w-fit px-4"
>
{category}
@@ -232,74 +362,121 @@ export function IntegrationsGrid() {
{/* Integration Detail Modal */}
{selectedIntegration && (