diff --git a/apps/web/src/components/settings/DeploymentTimeZoneSetting.tsx b/apps/web/src/components/settings/DeploymentTimeZoneSetting.tsx
index e152c9441..6f39e6445 100644
--- a/apps/web/src/components/settings/DeploymentTimeZoneSetting.tsx
+++ b/apps/web/src/components/settings/DeploymentTimeZoneSetting.tsx
@@ -21,6 +21,7 @@ import {
Popover,
PopoverContent,
PopoverTrigger,
+ Skeleton,
} from '@/components/system';
const FALLBACK_TIME_ZONES = [
@@ -83,6 +84,10 @@ export function DeploymentTimeZoneSetting() {
const effectiveTimeZone = settings.data?.effectiveTimeZone ?? 'UTC';
+ if (settings.isPending) {
+ return ;
+ }
+
if (!isEditing) {
return (
diff --git a/apps/web/src/components/settings/automations/AutomationsSettings.render.client.test.tsx b/apps/web/src/components/settings/automations/AutomationsSettings.render.client.test.tsx
index 18219b032..eede21ef9 100644
--- a/apps/web/src/components/settings/automations/AutomationsSettings.render.client.test.tsx
+++ b/apps/web/src/components/settings/automations/AutomationsSettings.render.client.test.tsx
@@ -10,6 +10,7 @@ const managerInstructionsPlaceholder =
/Optional guidance for which ideas to prioritize or avoid/;
const state = vi.hoisted(() => ({
+ customAutomationsPending: false,
customAutomations: [] as Array<{
id: string;
name: string;
@@ -284,7 +285,10 @@ vi.mock('@tanstack/react-query', () => ({
}
if (key1 === 'listCustomAutomations') {
- return { isPending: false, data: state.customAutomations };
+ return {
+ isPending: state.customAutomationsPending,
+ data: state.customAutomations,
+ };
}
if (queryOptions.queryKey?.[0] === 'taskModels') {
@@ -522,6 +526,8 @@ describe('AutomationsSettings', () => {
state.settingsQuery.data.reviewer.relayReviewResultsToTask = false;
state.settingsQuery.data.reviewer.relayUsers = [];
state.customAutomations = [];
+ state.customAutomationsPending = false;
+ state.settingsQuery.isPending = false;
state.environments = [];
for (const key of Object.keys(
state.settingsQuery.data.resolvedDestinations,
@@ -776,6 +782,50 @@ describe('AutomationsSettings', () => {
expect(screen.queryByText('Meta automations')).toBeNull();
});
+ it('filters available automations by category and provider-aware search', async () => {
+ render();
+
+ const categoryFilter = await screen.findByRole('combobox', {
+ name: 'Filter available automations by category',
+ });
+ expect(categoryFilter).toHaveTextContent('All');
+
+ fireEvent.change(
+ screen.getByRole('textbox', { name: 'Search available automations' }),
+ { target: { value: 'Discord' } },
+ );
+
+ expect(screen.getByText('Auto-respond to channels')).toBeInTheDocument();
+ expect(screen.queryByText('Review Code')).not.toBeInTheDocument();
+ fireEvent.click(
+ screen.getByRole('button', { name: 'Clear automation filters' }),
+ );
+ expect(screen.getByText('Review Code')).toBeInTheDocument();
+
+ fireEvent.click(categoryFilter);
+ fireEvent.click(await screen.findByRole('option', { name: 'Operations' }));
+ expect(screen.getByText('Triage Sentry Issues')).toBeInTheDocument();
+ expect(screen.queryByText('Review Code')).not.toBeInTheDocument();
+ });
+
+ it('shows independent structural skeletons for custom and built-in automations', () => {
+ state.customAutomationsPending = true;
+ state.settingsQuery.isPending = true;
+
+ render();
+
+ expect(
+ screen
+ .getByTestId('custom-automations-skeleton')
+ .querySelectorAll('[data-slot="skeleton"]'),
+ ).toHaveLength(6);
+ expect(
+ screen
+ .getByTestId('built-in-automations-skeleton')
+ .querySelectorAll('[data-slot="skeleton"]'),
+ ).toHaveLength(17);
+ });
+
it('uses plain text empty states for built-in and custom automations', async () => {
state.settingsQuery.data.settings.channelAutoStartSlackChannels = [];
state.settingsQuery.data.settings.managerSlackChannelId = null as never;
@@ -857,6 +907,17 @@ describe('AutomationsSettings', () => {
expect(
await screen.findByRole('dialog', { name: 'Edit custom automation' }),
).toBeInTheDocument();
+ expect(screen.getByText('Schedule')).toBeInTheDocument();
+ expect(screen.getByText('Destination')).toBeInTheDocument();
+ expect(screen.getByText('Channel')).toBeInTheDocument();
+ expect(screen.queryByText('Cadence')).not.toBeInTheDocument();
+ expect(screen.queryByText('Frequency')).not.toBeInTheDocument();
+ expect(screen.queryByText('Destination provider')).not.toBeInTheDocument();
+ expect(
+ screen.queryByText(
+ 'Configure what runs, when it runs, and where the result is sent.',
+ ),
+ ).not.toBeInTheDocument();
});
it('reflects the reviewer all-author setting in the review scope copy', async () => {
diff --git a/apps/web/src/components/settings/automations/AutomationsSettings.tsx b/apps/web/src/components/settings/automations/AutomationsSettings.tsx
index da31d3ca0..e054eff12 100644
--- a/apps/web/src/components/settings/automations/AutomationsSettings.tsx
+++ b/apps/web/src/components/settings/automations/AutomationsSettings.tsx
@@ -95,6 +95,7 @@ import {
Play,
Plus,
RefreshCcw,
+ Search,
Select,
SelectContent,
SelectItem,
@@ -111,6 +112,7 @@ import {
Textarea,
TriangleAlert,
Users,
+ X,
} from '@/components/system';
type FieldErrors = Partial<
@@ -228,12 +230,26 @@ type AutomationDefinition = {
label: string;
description: string;
icon: ComponentType<{ className?: string }>;
+ category: AutomationCategory;
+ searchTerms?: string[];
/** Compact label for the chat surfaces the automation can report to. */
commsBadge?: string;
/** Compact label for the source-control providers the automation supports. */
scmBadge?: string;
};
+type AutomationCategory = 'source-code' | 'communication' | 'operations';
+
+const AUTOMATION_CATEGORY_OPTIONS: Array<{
+ value: AutomationCategory | 'all';
+ label: string;
+}> = [
+ { value: 'all', label: 'All' },
+ { value: 'source-code', label: 'Source code' },
+ { value: 'communication', label: 'Communication' },
+ { value: 'operations', label: 'Operations' },
+];
+
/**
* Where an automation's next run will report, as resolved server-side through
* the destination waterfall (own target -> Manager Channel -> primary
@@ -335,12 +351,12 @@ const TRIGGERABLE_AUTOMATION_SCHEDULE_LABELS = {
*/
function getAutomationCapabilityBadges(
automationKey: BackgroundAutomationKey,
-): Pick {
+): Pick {
const descriptor =
getTriggerableBackgroundAutomationDescriptorByKey(automationKey);
if (!descriptor) {
- return {};
+ return { searchTerms: [] };
}
const comms: readonly CommunicationProvider[] =
@@ -364,6 +380,10 @@ function getAutomationCapabilityBadges(
: undefined;
return {
+ searchTerms: [
+ ...comms.map(getCommunicationProviderDisplayName),
+ ...scm.map(getSourceControlProviderLabel),
+ ],
...(commsBadge ? { commsBadge } : {}),
...(scmBadge ? { scmBadge } : {}),
};
@@ -386,6 +406,7 @@ function getAutomationDefinition(
label: descriptor.label,
description: TRIGGERABLE_AUTOMATION_DESCRIPTIONS[automationKey],
icon,
+ category: automationKey === 'sentry_triage' ? 'operations' : 'source-code',
...getAutomationCapabilityBadges(automationKey),
};
}
@@ -450,6 +471,7 @@ const SCHEDULE_ONLY_AUTOMATION_DEFINITIONS = Object.fromEntries(
description:
SCHEDULE_ONLY_AUTOMATION_UI_DEFINITIONS[automation.id].description,
icon: SCHEDULE_ONLY_AUTOMATION_UI_DEFINITIONS[automation.id].icon,
+ category: 'source-code',
...getAutomationCapabilityBadges(automation.automationKey),
},
]),
@@ -475,6 +497,8 @@ const AUTOMATION_DEFINITIONS: Record = {
description:
'Start tasks from selected Slack or Discord channels, each with its own custom instructions.',
icon: MessagesSquare,
+ category: 'communication',
+ searchTerms: ['Slack', 'Discord'],
},
managerChannel: {
id: 'managerChannel',
@@ -482,6 +506,8 @@ const AUTOMATION_DEFINITIONS: Record = {
description:
'Shared Slack or Discord channel for manager-facing Roomote asks, summaries, and alerts.',
icon: Users,
+ category: 'communication',
+ searchTerms: ['Slack', 'Discord'],
},
managerStats: {
...getAutomationDefinition(
@@ -489,6 +515,7 @@ const AUTOMATION_DEFINITIONS: Record = {
'manager_stats',
ChartColumnIncreasing,
),
+ category: 'communication',
},
sentryTriage: {
...getAutomationDefinition('sentryTriage', 'sentry_triage', SentryIcon),
@@ -509,6 +536,8 @@ const AUTOMATION_DEFINITIONS: Record = {
label: 'Review Code',
description: `Review PRs automatically and on-demand.`,
icon: GitPullRequest,
+ category: 'source-code',
+ searchTerms: sourceControlProviders.map(getSourceControlProviderLabel),
},
conflictResolver: {
...getAutomationDefinition(
@@ -519,9 +548,11 @@ const AUTOMATION_DEFINITIONS: Record = {
},
suggester: {
...getAutomationDefinition('suggester', 'suggester', Lightbulb),
+ category: 'communication',
},
announcer: {
...getAutomationDefinition('announcer', 'announcer', Megaphone),
+ category: 'communication',
},
platformIssueAlerts: {
id: 'platformIssueAlerts',
@@ -529,6 +560,8 @@ const AUTOMATION_DEFINITIONS: Record = {
description:
'Alert on Slack or Discord when a task runs into admin-fixable issues.',
icon: BellElectric,
+ category: 'operations',
+ searchTerms: ['Slack', 'Discord'],
},
};
@@ -1176,8 +1209,12 @@ function shouldShowChannelAutoStartWarning(params: {
function LoadingSkeleton() {
return (
-
- {Array.from({ length: 6 }).map((_, index) => (
+
+
+ {Array.from({ length: 4 }).map((_, index) => (
@@ -1434,6 +1471,7 @@ function AutomationCard({
isOpen,
onOpenChange,
iconEnabled,
+ isAvailableMatch = true,
runAction,
debugSection,
footer,
@@ -1445,6 +1483,7 @@ function AutomationCard({
isOpen: boolean;
onOpenChange: (open: boolean) => void;
iconEnabled: boolean;
+ isAvailableMatch?: boolean;
runAction?: React.ReactNode;
debugSection?: React.ReactNode;
footer?: React.ReactNode;
@@ -1458,6 +1497,10 @@ function AutomationCard({
? `Configure ${automation.label}`
: `Set up ${automation.label}`;
+ if (!iconEnabled && !isAvailableMatch) {
+ return null;
+ }
+
return (
({
isOpen,
onOpenChange,
iconEnabled,
+ isAvailableMatch,
disabled = false,
debugSection,
runTooltip,
@@ -1556,6 +1600,7 @@ function ScheduledAutomationCard
({
isOpen: boolean;
onOpenChange: (open: boolean) => void;
iconEnabled: boolean;
+ isAvailableMatch: boolean;
disabled?: boolean;
debugSection?: React.ReactNode;
runTooltip: string;
@@ -1579,6 +1624,7 @@ function ScheduledAutomationCard({
isOpen={isOpen}
onOpenChange={onOpenChange}
iconEnabled={iconEnabled}
+ isAvailableMatch={isAvailableMatch}
disabled={disabled}
debugSection={debugSection}
runAction={
@@ -1655,6 +1701,10 @@ export function AutomationsSettings() {
const [isEditingManagerChannel, setIsEditingManagerChannel] = useState(false);
const [isEnteringCustomManagerChannel, setIsEnteringCustomManagerChannel] =
useState(false);
+ const [availableCategory, setAvailableCategory] = useState<
+ AutomationCategory | 'all'
+ >('all');
+ const [availableSearch, setAvailableSearch] = useState('');
const formStateRef = useRef(null);
const savedStateRef = useRef(null);
const didApplyInitialHashRef = useRef(false);
@@ -2539,6 +2589,28 @@ export function AutomationsSettings() {
announcer: announcerIsEnabled,
platformIssueAlerts: isPlatformIssueAlertsEnabled(formState),
} satisfies Record;
+ const normalizedAvailableSearch = availableSearch.trim().toLowerCase();
+ const availableAutomationMatches = new Set(
+ Object.values(AUTOMATION_DEFINITIONS)
+ .filter(
+ (automation) =>
+ !iconEnabled[automation.id] &&
+ (availableCategory === 'all' ||
+ automation.category === availableCategory) &&
+ (!normalizedAvailableSearch ||
+ [
+ automation.label,
+ automation.description,
+ ...(automation.searchTerms ?? []),
+ ]
+ .join(' ')
+ .toLowerCase()
+ .includes(normalizedAvailableSearch)),
+ )
+ .map((automation) => automation.id),
+ );
+ const hasAvailableFilters =
+ availableCategory !== 'all' || Boolean(normalizedAvailableSearch);
const isAutomationSaving = (automationId: AutomationId) =>
updateMutation.isPending && savingAutomation === automationId;
@@ -2652,6 +2724,8 @@ export function AutomationsSettings() {
) : null}
+
+
{settingsQuery.isPending || !formState ? (
) : (
@@ -2665,11 +2739,71 @@ export function AutomationsSettings() {
No built-in automations enabled yet.
)}
-
- Available
-
+
+
+ Available
+
+
+
+
+
+
+ setAvailableSearch(event.currentTarget.value)
+ }
+ placeholder="Search"
+ aria-label="Search available automations"
+ className="h-8 w-36 pl-8 text-sm"
+ />
+
+ {hasAvailableFilters ? (
+
+
+
+ ) : null}
+
+
+ {availableAutomationMatches.size === 0 ? (
+
+ No available automations match these filters.
+
+ ) : null}
setAutomationOpen('reviewer', open)}
iconEnabled={iconEnabled.reviewer}
@@ -2818,6 +2952,9 @@ export function AutomationsSettings() {
setAutomationOpen(automation.id, open)
@@ -2894,6 +3031,9 @@ export function AutomationsSettings() {
setAutomationOpen('conflictResolver', open)
@@ -3100,6 +3240,9 @@ export function AutomationsSettings() {
setAutomationOpen(automation.id, open)
@@ -3212,6 +3355,9 @@ export function AutomationsSettings() {
setAutomationOpen('dependabotTriage', open)
@@ -3287,6 +3433,7 @@ export function AutomationsSettings() {
setAutomationOpen('codeqlTriage', open)}
iconEnabled={iconEnabled.codeqlTriage}
@@ -3377,6 +3524,9 @@ export function AutomationsSettings() {
setAutomationOpen(automation.id, open)
@@ -3487,10 +3637,11 @@ export function AutomationsSettings() {
);
})}
-
-
setAutomationOpen('channelAutoStart', open)
@@ -3546,6 +3697,9 @@ export function AutomationsSettings() {
setAutomationOpen('managerChannel', open)}
iconEnabled={iconEnabled.managerChannel}
@@ -3814,6 +3968,7 @@ export function AutomationsSettings() {
setAutomationOpen('managerStats', open)}
iconEnabled={iconEnabled.managerStats}
@@ -3826,7 +3981,9 @@ export function AutomationsSettings() {
variant="ghost"
size="icon"
onClick={() =>
- triggerMutation.mutate({ automationKey: 'manager_stats' })
+ triggerMutation.mutate({
+ automationKey: 'manager_stats',
+ })
}
disabled={isRunDisabled(
'managerStats',
@@ -3898,6 +4055,7 @@ export function AutomationsSettings() {
setAutomationOpen('sentryTriage', open)}
iconEnabled={iconEnabled.sentryTriage}
@@ -3914,7 +4072,9 @@ export function AutomationsSettings() {
variant="ghost"
size="icon"
onClick={() =>
- triggerMutation.mutate({ automationKey: 'sentry_triage' })
+ triggerMutation.mutate({
+ automationKey: 'sentry_triage',
+ })
}
disabled={isRunDisabled(
'sentryTriage',
@@ -4074,6 +4234,7 @@ export function AutomationsSettings() {
setAutomationOpen('suggester', open)}
iconEnabled={iconEnabled.suggester}
@@ -4197,6 +4358,7 @@ export function AutomationsSettings() {
setAutomationOpen('announcer', open)}
iconEnabled={iconEnabled.announcer}
@@ -4310,6 +4472,9 @@ export function AutomationsSettings() {
setAutomationOpen('platformIssueAlerts', open)
diff --git a/apps/web/src/components/settings/automations/CustomAutomationsSection.tsx b/apps/web/src/components/settings/automations/CustomAutomationsSection.tsx
index a4673d6ff..e23fff447 100644
--- a/apps/web/src/components/settings/automations/CustomAutomationsSection.tsx
+++ b/apps/web/src/components/settings/automations/CustomAutomationsSection.tsx
@@ -20,7 +20,6 @@ import {
CardContent,
Dialog,
DialogContent,
- DialogDescription,
DialogHeader,
DialogTitle,
Input,
@@ -31,7 +30,8 @@ import {
SelectItem,
SelectTrigger,
SelectValue,
- SlidersHorizontal,
+ Settings2,
+ Skeleton,
Switch,
Textarea,
Trash2,
@@ -440,14 +440,11 @@ export function CustomAutomationsSection() {
};
const renderEditor = () => (
-
+
{editingId ? 'Edit custom automation' : 'New custom automation'}
-
- Configure what runs, when it runs, and where the result is sent.
-
@@ -482,9 +479,9 @@ export function CustomAutomationsSection() {
/>
-
-
-
+
+
+
-
- {form.scheduleMode === 'cron' ? (
-
-
+ {form.scheduleMode === 'cron' ? (
- {resolveScheduleMutation.isPending ? (
-
- Interpreting schedule...
-
- ) : effectiveScheduleSummary ? (
-
- {effectiveScheduleSummary}
-
- ) : null}
-
+ ) : null}
+
+ {resolveScheduleMutation.isPending ? (
+
+ Interpreting schedule...
+
+ ) : effectiveScheduleSummary ? (
+
+ {effectiveScheduleSummary}
+
) : null}
-
-
-
+
+
+