From ca3bd76f3baae96675aac548595e0da629bd676b Mon Sep 17 00:00:00 2001 From: "roomote-community[bot]" <311835222+roomote-community[bot]@users.noreply.github.com> Date: Mon, 3 Aug 2026 01:33:59 -0400 Subject: [PATCH 01/24] [Fix] Discord automation replies disappear after task resume (#1017) Co-authored-by: Matt Rubens <2600+mrubens@users.noreply.github.com> --- .../communication-thread-replies.test.ts | 41 +++++++++++++++++++ .../mcp/communication-thread-replies.ts | 18 +++++--- .../handlers/__tests__/auto-resume.test.ts | 39 ++++++++++++++++++ .../preview-proxy/src/handlers/auto-resume.ts | 4 ++ apps/web/src/trpc/commands/snapshots/index.ts | 4 ++ 5 files changed, 100 insertions(+), 6 deletions(-) diff --git a/apps/api/src/handlers/mcp/__tests__/communication-thread-replies.test.ts b/apps/api/src/handlers/mcp/__tests__/communication-thread-replies.test.ts index 8f7fd6347..aaf6ada5c 100644 --- a/apps/api/src/handlers/mcp/__tests__/communication-thread-replies.test.ts +++ b/apps/api/src/handlers/mcp/__tests__/communication-thread-replies.test.ts @@ -304,6 +304,47 @@ describe('maybeSendCommunicationThreadReply (Discord)', () => { expect(discordCreateThreadFromMessageMock).not.toHaveBeenCalled(); }); + it('recovers a missing automation thread from its saved root message', async () => { + getTaskAutomationInitiatorKeyMock.mockResolvedValue('custom_automation'); + + await maybeSendCommunicationThreadReply({ + taskRun: { + ...discordTaskRun, + payload: { + communicationProvider: 'discord', + communicationChannelId: 'channel-1', + communicationMessageId: 'report-root', + }, + }, + parsedBody: { text: 'Follow-up result', images: [] }, + }); + + expect(discordPostMessageMock).toHaveBeenCalledWith( + expect.objectContaining({ replyToMessageId: 'report-root' }), + ); + expect(discordCreateThreadFromMessageMock).toHaveBeenCalledWith({ + channelId: 'channel-1', + messageId: 'report-root', + name: 'Follow-up result', + }); + expect( + sqlMock.mock.calls.map(([strings, ...values]) => ({ + text: Array.from(strings as string[]).join('?'), + values, + })), + ).toContainEqual( + expect.objectContaining({ + text: expect.stringContaining("communicationThreadId' IS NULL"), + values: expect.arrayContaining(['report-root']), + }), + ); + expect(sqlMock.mock.calls.flatMap((call) => call.slice(1))).toContainEqual( + expect.stringContaining( + '"communicationThreadId":"automation-thread-1","discordTaskThread":true', + ), + ); + }); + it('attaches to the investigating opener when only a root message id is present', async () => { const response = await maybeSendCommunicationThreadReply({ taskRun: { diff --git a/apps/api/src/handlers/mcp/communication-thread-replies.ts b/apps/api/src/handlers/mcp/communication-thread-replies.ts index 399b3eacf..a3c5de2bd 100644 --- a/apps/api/src/handlers/mcp/communication-thread-replies.ts +++ b/apps/api/src/handlers/mcp/communication-thread-replies.ts @@ -237,6 +237,15 @@ async function bindLateCommunicationReportThread(params: { } : {}), }); + const unboundReportCondition = discordThread + ? sql`( + ${taskRuns.payload}->>'communicationMessageId' IS NULL + OR ( + ${taskRuns.payload}->>'communicationMessageId' = ${params.messageId} + AND ${taskRuns.payload}->>'communicationThreadId' IS NULL + ) + )` + : sql`${taskRuns.payload}->>'communicationMessageId' IS NULL`; await db.transaction(async (tx) => { const boundRuns = await tx .update(taskRuns) @@ -244,10 +253,7 @@ async function bindLateCommunicationReportThread(params: { payload: sql`coalesce(${taskRuns.payload}, '{}'::jsonb) || ${patch}::jsonb`, }) .where( - and( - eq(taskRuns.taskId, params.taskRun.taskId), - sql`${taskRuns.payload}->>'communicationMessageId' IS NULL`, - ), + and(eq(taskRuns.taskId, params.taskRun.taskId), unboundReportCondition), ) .returning({ id: taskRuns.id }); @@ -695,8 +701,8 @@ async function sendDiscordThreadReply(params: { await bindLateCommunicationReportThread({ taskRun: params.taskRun, provider: 'discord', - messageId: reply.messageId, - ...(!threadId && !messageId + messageId: messageId ?? reply.messageId, + ...(!threadId ? { discordProvider: provider, ...(text ? { discordThreadName: text } : {}), diff --git a/apps/preview-proxy/src/handlers/__tests__/auto-resume.test.ts b/apps/preview-proxy/src/handlers/__tests__/auto-resume.test.ts index 9488ecad6..399e46858 100644 --- a/apps/preview-proxy/src/handlers/__tests__/auto-resume.test.ts +++ b/apps/preview-proxy/src/handlers/__tests__/auto-resume.test.ts @@ -138,4 +138,43 @@ describe('triggerAutoResume', () => { }), ); }); + + it('preserves Discord reply context when creating a snapshot resume run', async () => { + const resolution = createMockResolvedRequest({ + status: 'resumable', + snapshotId: 'snap-preview-discord', + taskRun: { + ...createMockTaskRun({ + id: 44, + payload: { + repo: 'owner/repo', + communicationProvider: 'discord', + communicationChannelId: 'channel-1', + communicationThreadId: 'thread-1', + communicationMessageId: 'message-1', + }, + }), + port: 3000, + }, + }); + + await triggerAutoResume(resolution, { + userId: 'viewer-user', + tokenType: 'pt', + version: 1, + }); + + expect(mockEnqueueTask).toHaveBeenCalledWith( + expect.objectContaining({ + task: expect.objectContaining({ + payload: expect.objectContaining({ + communicationProvider: 'discord', + communicationChannelId: 'channel-1', + communicationThreadId: 'thread-1', + communicationMessageId: 'message-1', + }), + }), + }), + ); + }); }); diff --git a/apps/preview-proxy/src/handlers/auto-resume.ts b/apps/preview-proxy/src/handlers/auto-resume.ts index a0327ee2b..cc8e08c51 100644 --- a/apps/preview-proxy/src/handlers/auto-resume.ts +++ b/apps/preview-proxy/src/handlers/auto-resume.ts @@ -2,6 +2,7 @@ import { type TaskPayload, TaskPayloadKind, activeRunStatuses, + populateSnapshotResumeCommunicationMetadata, populateSnapshotResumeSlackMetadata, restoreSnapshotResumeVisiblePromptFields, type PreviewTokenContext, @@ -111,6 +112,9 @@ export async function triggerAutoResume( populateSnapshotResumeSlackMetadata(payload, { sourcePayload: taskRun.payload, }); + populateSnapshotResumeCommunicationMetadata(payload, { + sourcePayload: taskRun.payload, + }); restoreSnapshotResumeVisiblePromptFields(payload, sourcePayload); // Resumes never create tasks and carry no initiator; the resuming human diff --git a/apps/web/src/trpc/commands/snapshots/index.ts b/apps/web/src/trpc/commands/snapshots/index.ts index cb20ba581..f1ed630c7 100644 --- a/apps/web/src/trpc/commands/snapshots/index.ts +++ b/apps/web/src/trpc/commands/snapshots/index.ts @@ -4,6 +4,7 @@ import { runningRunStatuses, TaskPayloadKind, ORPHANED_PENDING_THRESHOLD_MS, + populateSnapshotResumeCommunicationMetadata, populateSnapshotResumeSlackMetadata, EXPIRED_SNAPSHOT_RESUME_ERROR, isTaskResumeCapableComputeProvider, @@ -494,6 +495,9 @@ export async function restoreTaskRunSnapshotCommand( sourcePayload, threadTs: sourceTask.slackThreadTs, }); + populateSnapshotResumeCommunicationMetadata(payload, { + sourcePayload, + }); await inheritSnapshotResumeVisiblePromptFields( payload, From 40b0c7c446eea253f3efffa6de94e966c6319a8a Mon Sep 17 00:00:00 2001 From: "roomote-roomote[bot]" <301996811+roomote-roomote[bot]@users.noreply.github.com> Date: Mon, 3 Aug 2026 10:49:16 +0100 Subject: [PATCH 02/24] [Improve] Add personal settings shortcut to user menu (#1019) * feat: add personal settings shortcut to user menu * fix: close user menu after settings navigation --------- Co-authored-by: Roomote --- apps/web/src/app/(centered)/layout.tsx | 2 +- .../(onboarding)/setup/SetupLayoutClient.tsx | 1 + .../layout/UserMenu.client.test.tsx | 87 +++++++++++++++++++ apps/web/src/components/layout/UserMenu.tsx | 24 ++++- 4 files changed, 110 insertions(+), 4 deletions(-) create mode 100644 apps/web/src/components/layout/UserMenu.client.test.tsx diff --git a/apps/web/src/app/(centered)/layout.tsx b/apps/web/src/app/(centered)/layout.tsx index faaea15d3..6c0cce9e5 100644 --- a/apps/web/src/app/(centered)/layout.tsx +++ b/apps/web/src/app/(centered)/layout.tsx @@ -36,7 +36,7 @@ export default function OnboardingLayout({ surfaceClassName="relative flex items-center justify-center" >
- +
{children}
diff --git a/apps/web/src/app/(onboarding)/setup/SetupLayoutClient.tsx b/apps/web/src/app/(onboarding)/setup/SetupLayoutClient.tsx index d1d42f39b..d32fdd091 100644 --- a/apps/web/src/app/(onboarding)/setup/SetupLayoutClient.tsx +++ b/apps/web/src/app/(onboarding)/setup/SetupLayoutClient.tsx @@ -88,6 +88,7 @@ export function SetupLayoutClient({ children }: { children: React.ReactNode }) { diff --git a/apps/web/src/components/layout/UserMenu.client.test.tsx b/apps/web/src/components/layout/UserMenu.client.test.tsx new file mode 100644 index 000000000..07c300b1b --- /dev/null +++ b/apps/web/src/components/layout/UserMenu.client.test.tsx @@ -0,0 +1,87 @@ +import type { ButtonHTMLAttributes, ReactNode } from 'react'; +import { render, screen } from '@testing-library/react'; + +const { useQueryMock } = vi.hoisted(() => ({ + useQueryMock: vi.fn(), +})); + +vi.mock('@tanstack/react-query', () => ({ + useQuery: useQueryMock, +})); + +vi.mock('@/components/system', async (importOriginal) => { + const actual = await importOriginal(); + + return { + ...actual, + DropdownMenu: ({ children }: { children: ReactNode }) => <>{children}, + DropdownMenuContent: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), + DropdownMenuItem: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), + DropdownMenuSeparator: () =>
, + DropdownMenuTrigger: ({ + children, + ...props + }: ButtonHTMLAttributes & { children: ReactNode }) => ( + + ), + }; +}); + +vi.mock('@/hooks/useUser', () => ({ + useUser: () => ({ + isSignedIn: true, + user: { + name: 'Ada Lovelace', + primaryEmail: 'ada@example.com', + resource: { + imageUrl: null, + primaryEmailAddress: { emailAddress: 'ada@example.com' }, + }, + }, + }), +})); + +vi.mock('@/trpc/client', () => ({ + useTRPC: () => ({ + releases: { + status: { + queryOptions: vi.fn(() => ({ queryKey: ['releases.status'] })), + }, + }, + }), +})); + +import { UserMenu } from './UserMenu'; + +describe('UserMenu', () => { + beforeEach(() => { + useQueryMock.mockReturnValue({ data: null }); + }); + + it('links to personal settings from the user summary', () => { + render(); + + const settingsLink = screen.getByRole('link', { + name: 'Personal settings', + }); + + expect(settingsLink).toHaveAttribute('href', '/settings/personal'); + expect( + settingsLink.closest('[data-slot="dropdown-menu-item"]'), + ).toBeInTheDocument(); + }); + + it('hides personal settings while setup is incomplete', () => { + render(); + + expect( + screen.queryByRole('link', { name: 'Personal settings' }), + ).not.toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/components/layout/UserMenu.tsx b/apps/web/src/components/layout/UserMenu.tsx index f355b353e..6f21bb0a9 100644 --- a/apps/web/src/components/layout/UserMenu.tsx +++ b/apps/web/src/components/layout/UserMenu.tsx @@ -2,6 +2,8 @@ import { useState } from 'react'; import { useQuery } from '@tanstack/react-query'; +import Link from 'next/link'; +import Image from 'next/image'; import { Avatar, @@ -15,6 +17,7 @@ import { ExternalLink, Info, LogOut, + Settings, } from '@/components/system'; import { useUser } from '@/hooks/useUser'; @@ -22,6 +25,7 @@ import { authClient } from '@/lib/auth-client'; import { DOCS_BASE_URL } from '@/lib/docs'; import { isParsableProductVersion, toReleaseTag } from '@/lib/product-version'; import { GITHUB_RELEASES_BASE_URL } from '@/lib/release-links'; +import { SETTINGS_PATHS } from '@/lib/settings'; import { PERSONAL_THEME_STORAGE_KEY } from '@/types/preferences'; import { cn } from '@/lib/utils'; import { useTRPC } from '@/trpc/client'; @@ -33,17 +37,18 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/system'; -import Image from 'next/image'; export const UserMenu = ({ portalContainer, expanded = false, menuSide = 'left', + showPersonalSettings = true, switchOrgRedirectPath, }: { portalContainer?: HTMLElement | null; expanded?: boolean; menuSide?: 'top' | 'right' | 'bottom' | 'left'; + showPersonalSettings?: boolean; switchOrgRedirectPath?: string; } = {}) => { const { isSignedIn, user } = useUser(); @@ -57,6 +62,7 @@ export const UserMenu = ({ expanded={expanded} menuSide={menuSide} portalContainer={portalContainer} + showPersonalSettings={showPersonalSettings} switchOrgRedirectPath={switchOrgRedirectPath} user={user} /> @@ -67,11 +73,13 @@ function SignedInUserMenu({ portalContainer, expanded, menuSide, + showPersonalSettings, user, }: { portalContainer?: HTMLElement | null; expanded: boolean; menuSide: 'top' | 'right' | 'bottom' | 'left'; + showPersonalSettings: boolean; switchOrgRedirectPath?: string; user: NonNullable['user']>; }) { @@ -140,8 +148,8 @@ function SignedInUserMenu({ size="md" alt={userDisplayName} /> -
-
+
+
{userDisplayName}
@@ -149,6 +157,16 @@ function SignedInUserMenu({ {userEmail}
+ {showPersonalSettings ? ( + + + + + + ) : null}
From 9c5e488f3025347c8d64b4961f3c490d822c5fd8 Mon Sep 17 00:00:00 2001 From: "roomote-roomote[bot]" <301996811+roomote-roomote[bot]@users.noreply.github.com> Date: Mon, 3 Aug 2026 10:55:50 +0100 Subject: [PATCH 03/24] [Improve] Rework Automations settings experience (#1020) * feat: rework automations settings UX * improve: refine automation card details --------- Co-authored-by: Roomote --- apps/docs/automations.mdx | 20 +- ...AutomationsSettings.render.client.test.tsx | 239 ++- .../automations/AutomationsSettings.tsx | 1683 ++++++++--------- .../automations/CustomAutomationsSection.tsx | 388 ++-- .../src/components/system/primitives/icons.ts | 1 + 5 files changed, 1236 insertions(+), 1095 deletions(-) diff --git a/apps/docs/automations.mdx b/apps/docs/automations.mdx index eeff3f4bd..185bc476a 100644 --- a/apps/docs/automations.mdx +++ b/apps/docs/automations.mdx @@ -78,11 +78,9 @@ Create arbitrary scheduled agent runs with: Admins can also choose **Custom schedule** and enter either a standard five-field cron expression or a natural-language schedule such as “weekdays at -9am.” Roomote previews the interpreted schedule before saving. A schedule that -names a day but no time runs at 3am local, matching the daily and weekly -cadences, and Roomote asks for clarification rather than guessing when the -recurrence itself is ambiguous. Custom schedules do not support seconds or -cron macros. +9am.” Roomote previews the interpreted schedule before saving and asks for +clarification rather than guessing when the recurrence itself is ambiguous. +Custom schedules do not support seconds or cron macros. On each due tick, Roomote launches a normal task with that prompt in that environment. When a report destination is set, the run is anchored to that @@ -93,9 +91,7 @@ chatter in between. Without a destination, the run happens silently and its results appear only in the task view. Use **Run now** on an enabled automation to test it immediately. -Daily and weekly custom automations use the same local-hour window as the -other scheduled automations (around 3am in the workspace timezone). Cap is -25 custom automations per deployment. +The deployment cap is 25 custom automations. The **Scheduling timezone** setting is available on both the Automations and Deployment settings pages. It applies to all scheduled automations and to @@ -179,10 +175,10 @@ runs) and posts digests there. You cannot pick an existing Telegram thread — Roomote owns the recurring topic. When you select Teams, digests go to the primary Teams conversation captured for the deployment. -Cards also show capability badges for what each automation supports today: -the chat surfaces it can report to and the source-control providers it works -with. Triage Dependabot Alerts and Triage CodeQL Alerts are GitHub-only by -nature. CI Failure Triage supports GitHub Actions, GitLab Pipelines, Azure +Cards also show a plain provider-support line for what each automation supports +today: the chat surfaces it can report to and the source-control providers it +works with. Triage Dependabot Alerts and Triage CodeQL Alerts are GitHub-only +by nature. CI Failure Triage supports GitHub Actions, GitLab Pipelines, Azure DevOps builds, Bitbucket Pipelines, and Gitea Actions. Triage Issues supports GitHub, GitLab, and Gitea issues. Security Auditor, Code Quality Auditor, Weekly Manager 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 8a417aeb1..18219b032 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 @@ -1,9 +1,39 @@ import type { ReactNode } from 'react'; -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { + act, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react'; const managerInstructionsPlaceholder = /Optional guidance for which ideas to prioritize or avoid/; const state = vi.hoisted(() => ({ + customAutomations: [] as Array<{ + id: string; + name: string; + prompt: string; + enabled: boolean; + scheduleMode: 'weekly'; + cronExpression: null; + model: null; + environmentId: string; + target: { + provider: 'slack'; + externalRef: string; + metadata?: Record; + }; + lastRunAt: null; + lastSucceededAt: null; + lastFailedAt: null; + lastError: null; + lastLaunchedTaskId: null; + createdByName: string; + createdAt: Date; + updatedAt: Date; + }>, + environments: [] as Array<{ id: string; name: string }>, nextUpdateSettingsResult: null as { success: true; settings: Record; @@ -254,7 +284,7 @@ vi.mock('@tanstack/react-query', () => ({ } if (key1 === 'listCustomAutomations') { - return { isPending: false, data: [] }; + return { isPending: false, data: state.customAutomations }; } if (queryOptions.queryKey?.[0] === 'taskModels') { @@ -314,7 +344,7 @@ vi.mock('@tanstack/react-query', () => ({ ) { // environments.list and any leftover channel listszheimer if (queryOptions.queryKey?.[0] === 'environments') { - return { isPending: false, data: [] }; + return { isPending: false, data: state.environments }; } } @@ -436,16 +466,24 @@ import { AutomationsSettings } from './AutomationsSettings'; async function openSuggesterCard() { fireEvent.click( - await screen.findByRole('button', { name: 'Expand Suggest Ideas' }), + await screen.findByRole('button', { + name: /(?:Set up|Configure) Suggest Ideas/, + }), ); } async function openReviewerCard() { fireEvent.click( - await screen.findByRole('button', { name: 'Expand Review Code' }), + await screen.findByRole('button', { + name: /(?:Set up|Configure) Review Code/, + }), ); } +function closeAutomationDialog() { + fireEvent.click(screen.getByRole('button', { name: 'Close' })); +} + describe('AutomationsSettings', () => { beforeEach(() => { vi.clearAllMocks(); @@ -459,8 +497,17 @@ describe('AutomationsSettings', () => { state.settingsQuery.data.settings.announcerDiscordChannelId = null; state.settingsQuery.data.settings.platformIssueDiscordChannelId = null; state.settingsQuery.data.settings.managerSlackChannelId = 'C123MANAGER'; + state.settingsQuery.data.slackChannelDisplayNames.managerSlackChannel = + '#roomote-managers'; state.settingsQuery.data.settings.managerDiscordChannelId = null; state.settingsQuery.data.settings.managerStatsFrequency = 'off' as never; + state.settingsQuery.data.settings.channelAutoStartSlackChannels = [ + { + channelId: 'C123BUGS', + instructions: 'Treat each message as a bug report.', + launchMode: 'always_start' as const, + }, + ]; state.settingsQuery.data.settings.sentryTriageFrequency = 'off' as never; state.settingsQuery.data.settings.dependabotTriageFrequency = 'off' as never; @@ -474,6 +521,8 @@ describe('AutomationsSettings', () => { state.settingsQuery.data.settings.reviewCodeInstructions = null; state.settingsQuery.data.reviewer.relayReviewResultsToTask = false; state.settingsQuery.data.reviewer.relayUsers = []; + state.customAutomations = []; + state.environments = []; for (const key of Object.keys( state.settingsQuery.data.resolvedDestinations, )) { @@ -493,7 +542,7 @@ describe('AutomationsSettings', () => { expect( await screen.findByRole('button', { - name: 'Expand Weekly Manager Stats', + name: /(?:Set up|Configure) Weekly Manager Stats/, }), ).toBeInTheDocument(); expect(screen.queryByText('Beta')).not.toBeInTheDocument(); @@ -529,30 +578,38 @@ describe('AutomationsSettings', () => { fireEvent.click( await screen.findByRole('button', { - name: 'Expand Weekly Manager Stats', + name: /(?:Set up|Configure) Weekly Manager Stats/, }), ); + expect( + screen.getByLabelText('Post summaries to this Slack channel'), + ).toBeInTheDocument(); + expect( + screen.getByText('Reports to: not configured — set a Manager Channel.'), + ).toBeInTheDocument(); + closeAutomationDialog(); fireEvent.click( - screen.getByRole('button', { name: 'Expand Triage Sentry Issues' }), + screen.getByRole('button', { + name: /(?:Set up|Configure) Triage Sentry Issues/, + }), ); + expect( + screen.getByLabelText('Post follow-up work to this Slack channel'), + ).toBeInTheDocument(); + closeAutomationDialog(); fireEvent.click( - screen.getByRole('button', { name: 'Expand Triage Dependabot Alerts' }), + screen.getByRole('button', { + name: /(?:Set up|Configure) Triage Dependabot Alerts/, + }), ); expect( - screen.getByLabelText('Post summaries to this Slack channel'), + screen.getByLabelText('Post follow-up work to this Slack channel'), ).toBeInTheDocument(); + expect(screen.getByText('Select a Slack channel')).toBeInTheDocument(); expect( - screen.getAllByLabelText('Post follow-up work to this Slack channel') - .length, - ).toBeGreaterThan(1); - expect( - screen.getAllByText('Select a Slack channel').length, - ).toBeGreaterThan(0); - expect( - screen.getAllByText('Reports to: not configured — set a Manager Channel.') - .length, - ).toBeGreaterThan(1); + screen.getByText('Reports to: not configured — set a Manager Channel.'), + ).toBeInTheDocument(); }); it('shows a saved Discord destination and a provider-neutral placeholder when Discord is connected', async () => { @@ -576,19 +633,22 @@ describe('AutomationsSettings', () => { fireEvent.click( await screen.findByRole('button', { - name: 'Expand Weekly Manager Stats', + name: /(?:Set up|Configure) Weekly Manager Stats/, }), ); + expect( + screen.getByText('#automation-reports (Discord)'), + ).toBeInTheDocument(); + closeAutomationDialog(); fireEvent.click( - screen.getByRole('button', { name: 'Expand Triage Sentry Issues' }), + screen.getByRole('button', { + name: /(?:Set up|Configure) Triage Sentry Issues/, + }), ); // The saved Discord channel is the selected destination. - expect( - screen.getByText('#automation-reports (Discord)'), - ).toBeInTheDocument(); // Pickers without a saved value use the provider-neutral placeholder. - expect(screen.getAllByText('Select a channel').length).toBeGreaterThan(0); + expect(screen.getByText('Select a channel')).toBeInTheDocument(); expect( screen.queryByText('Select a Slack channel'), ).not.toBeInTheDocument(); @@ -611,6 +671,11 @@ describe('AutomationsSettings', () => { render(); + fireEvent.click( + await screen.findByRole('button', { + name: /(?:Set up|Configure) Automation output/, + }), + ); const destination = await screen.findByRole('button', { name: /#automation-reports \(Discord\)/, }); @@ -642,7 +707,7 @@ describe('AutomationsSettings', () => { fireEvent.click( await screen.findByRole('button', { - name: 'Expand Alert on Config Errors', + name: /(?:Set up|Configure) Alert on Config Errors/, }), ); @@ -658,7 +723,7 @@ describe('AutomationsSettings', () => { render(); const expandButton = await screen.findByRole('button', { - name: 'Expand Auto-respond to channels', + name: /(?:Set up|Configure) Auto-respond to channels/, }); fireEvent.click(expandButton); @@ -693,43 +758,105 @@ describe('AutomationsSettings', () => { ).toBeInTheDocument(); }); - it('shows exception-only capability badges from the shared descriptors', async () => { + it('shows provider support as plain text instead of badges', async () => { + render(); + + await screen.findByText('Triage Dependabot Alerts'); + const providerSupport = screen.getAllByText('GitHub only')[0]!; + expect(providerSupport.tagName).toBe('P'); + expect(providerSupport).toHaveClass('text-sm', 'text-foreground'); + }); + + it('groups built-in automations into Enabled and Available sections', async () => { + render(); + + expect(await screen.findByText('Enabled')).toBeInTheDocument(); + expect(screen.getByText('Available')).toBeInTheDocument(); + expect(screen.queryByText('Source Code automations')).toBeNull(); + expect(screen.queryByText('Meta automations')).toBeNull(); + }); + + 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; + state.settingsQuery.data.slackChannelDisplayNames.managerSlackChannel = + null as never; + + render(); + + const builtInEmptyState = await screen.findByText( + 'No built-in automations enabled yet.', + ); + expect(builtInEmptyState.tagName).toBe('P'); + expect(builtInEmptyState).toHaveClass('text-sm', 'text-muted-foreground'); + const customEmptyState = screen.getByText( + 'No custom automations created yet.', + ); + expect(customEmptyState.tagName).toBe('P'); + expect(customEmptyState).toHaveClass('text-sm', 'text-muted-foreground'); + }); + + it('opens a built-in automation modal from its existing hash permalink', async () => { + window.location.hash = '#reviewer'; + render(); - // Dependabot and CodeQL stay GitHub-only; Issue Fixer supports - // GitHub/GitLab/Gitea, and manager stats is provider-neutral now and - // shows no source-control badge. - expect((await screen.findAllByText('GitHub only')).length).toBe(2); - // Full chat coverage for the suggester (no limited-comms badge); the other - // manager automations already cover all communication providers. - expect(screen.queryByText('Slack only')).toBeNull(); - expect(screen.queryByText('Slack · Discord · Telegram only')).toBeNull(); - expect(screen.queryByText(/Telegram only$/)).toBeNull(); - // conflict_resolver supports Gitea alongside GitHub, GitLab, and Azure - // DevOps. Bitbucket remains excluded because it has no conflict signal or - // label-based opt-in. ci_failure_triage covers all five SCM providers, so - // it no longer shows a limited-SCM badge. - expect( - screen.getAllByText('GitHub · GitLab · Azure DevOps · Gitea only').length, - ).toBe(1); expect( - screen.queryByText( - 'GitHub · GitLab · Azure DevOps · Bitbucket Cloud only', - ), - ).toBeNull(); - // Full coverage shows nothing — absence of a warning is the signal. - expect(screen.queryByText('All chat channels')).toBeNull(); - expect(screen.queryByText('All source control')).toBeNull(); + await screen.findByRole('dialog', { name: 'Review Code' }), + ).toBeInTheDocument(); }); - it('renders the Source Code and Meta automation sections', async () => { + it('renders custom automations as a compact control list and honors their permalinks', async () => { + state.environments = [{ id: 'env-1', name: 'Production' }]; + state.customAutomations = [ + { + id: 'automation-1', + name: 'Weekly flaky-test scan', + prompt: 'Find flaky tests.', + enabled: true, + scheduleMode: 'weekly', + cronExpression: null, + model: null, + environmentId: 'env-1', + target: { provider: 'slack', externalRef: 'C123MANAGER' }, + lastRunAt: null, + lastSucceededAt: null, + lastFailedAt: null, + lastError: null, + lastLaunchedTaskId: null, + createdByName: 'Ada', + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-01T00:00:00Z'), + }, + ]; render(); expect( - await screen.findByText('Source Code automations'), + await screen.findByRole('switch', { + name: 'Toggle Weekly flaky-test scan', + }), + ).toBeChecked(); + expect( + screen.getByText( + 'Weekly · Production · slack:#roomote-managers · Created by Ada', + ), + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { + name: 'Configure Weekly flaky-test scan', + }), + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'Delete Weekly flaky-test scan' }), + ).toBeInTheDocument(); + + act(() => { + window.location.hash = '#custom-automation-automation-1'; + window.dispatchEvent(new HashChangeEvent('hashchange')); + }); + expect( + await screen.findByRole('dialog', { name: 'Edit custom automation' }), ).toBeInTheDocument(); - expect(screen.getByText('Meta automations')).toBeInTheDocument(); - expect(screen.queryByText('Other automations')).toBeNull(); }); 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 3fcc30d10..da31d3ca0 100644 --- a/apps/web/src/components/settings/automations/AutomationsSettings.tsx +++ b/apps/web/src/components/settings/automations/AutomationsSettings.tsx @@ -71,21 +71,20 @@ import { AlertCircle, AlertDescription, AlertTitle, - Badge, BasicTooltip, BellElectric, BrandIcon, Button, Card, - CardContent, CardHeader, CardTitle, ChartColumnIncreasing, Check, - ChevronRight, - Collapsible, - CollapsibleContent, - CollapsibleTrigger, + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, GitMergeConflict, GitPullRequest, Info, @@ -94,6 +93,7 @@ import { Lightbulb, Megaphone, Play, + Plus, RefreshCcw, Select, SelectContent, @@ -106,6 +106,7 @@ import { SquarePen, Slack, Spinner, + Settings2, Switch, Textarea, TriangleAlert, @@ -532,6 +533,12 @@ const AUTOMATION_DEFINITIONS: Record = { }; const HASH_ALIAS_TO_AUTOMATION_ID: Record = { + ...Object.fromEntries( + Object.keys(AUTOMATION_DEFINITIONS).map((automationId) => [ + automationId.toLowerCase(), + automationId, + ]), + ), 'auto-respond-channels': 'channelAutoStart', autorespondchannels: 'channelAutoStart', 'auto-start-tasks': 'channelAutoStart', @@ -1447,25 +1454,59 @@ function AutomationCard({ }) { const Icon = automation.icon; const open = !disabled && (alwaysOpen || isOpen); - const content = ( - <> - - {children} - {debugSection} - - {footer ? ( -
{footer}
- ) : null} - - ); + const actionLabel = iconEnabled + ? `Configure ${automation.label}` + : `Set up ${automation.label}`; return (
- + +
+
+
+
+ +
+
+
+ {automation.label} + {automation.commsBadge || automation.scmBadge ? ( +

+ {[automation.commsBadge, automation.scmBadge] + .filter(Boolean) + .join(' · ')} +

+ ) : null} +

+ {automation.description} +

+
+
+
+ {runAction && iconEnabled && !disabled ? runAction : null} + + + +
+
+
+ + + { if (!disabled) { @@ -1473,129 +1514,22 @@ function AutomationCard({ } }} > - - -
- {alwaysOpen ? ( -
- -
- - - -

- {automation.description} -

-
-
- ) : disabled ? ( -
- - - - -
- - - -

- {automation.description} -

-
-
- ) : ( - -
- - - - -
- - - -

- {automation.description} -

-
-
-
- )} - {runAction && !disabled ? ( -
{runAction}
- ) : null} -
-
- {alwaysOpen && !disabled ? ( - content - ) : !disabled ? ( - - {content} - - ) : null} -
-
+ + + {automation.label} + {automation.description} + +
+ {children} + {debugSection} + {footer ?
{footer}
: null} +
+
+
); } -function AutomationTitle({ automation }: { automation: AutomationDefinition }) { - return ( - - {automation.label} - {automation.commsBadge ? ( - - {automation.commsBadge} - - ) : null} - {automation.scmBadge ? ( - - {automation.scmBadge} - - ) : null} - - ); -} - function ScheduledAutomationCard({ automation, isOpen, @@ -2116,6 +2050,13 @@ export function AutomationsSettings() { } return next; }); + + if (typeof window !== 'undefined') { + const nextUrl = open + ? `${window.location.pathname}${window.location.search}#${automationId}` + : `${window.location.pathname}${window.location.search}`; + window.history.replaceState(null, '', nextUrl); + } }, [], ); @@ -2715,11 +2656,18 @@ export function AutomationsSettings() { ) : (
-
-

- Source Code automations +
+

+ Enabled +

+ {Object.values(iconEnabled).some(Boolean) ? null : ( +

+ No built-in automations enabled yet. +

+ )} +

+ Available

- -

- Channel automations -

- -

- Automations for Roomote Managers -

+ setAutomationOpen('managerChannel', open)} + iconEnabled={iconEnabled.managerChannel} + > +
+ {showManagerChannelForm ? ( + <> + +

+ Make sure the Roomote app is added to the channel. +

+
+
+ { - if (value === CLEAR_MANAGER_CHANNEL_SELECT_VALUE) { setIsEnteringCustomManagerChannel(false); setFormState((prev) => prev ? { ...prev, - managerSlackChannel: '', + managerSlackChannel: selectedChannel.label, managerDiscordChannel: '', } : prev, ); - return; - } - - if (value === CUSTOM_MANAGER_CHANNEL_SELECT_VALUE) { + }} + disabled={managerChannelSelectionDisabled} + > + + + {managerChannelSelectLabel} + + + + {managerChannelHasValue ? ( + <> + + Clear selection + + + + ) : null} + {slackChannelsQuery.isPending || + discordChannelsQuery.isPending ? ( + + Loading channels... + + ) : slackChannelsQuery.isError || + discordChannelsQuery.isError ? ( + + Could not load channels. Try refreshing. + + ) : managerChannelOptions.length > 0 || + managerDiscordChannelOptions.length > 0 ? ( + [ + ...managerChannelOptions, + ...managerDiscordChannelOptions, + ].map((channel) => ( + + {channel.label} + + )) + ) : ( + + No channels found. + + )} + + + Private or manual channel + + + + {managerChannelOptions.length > 0 || + managerDiscordChannelOptions.length > 0 ? ( + + ) : null} +
+ {showCustomManagerChannelInput ? ( + { setIsEnteringCustomManagerChannel(true); - setFormState((prev) => - prev && selectedManagerChannelOption - ? { - ...prev, - managerSlackChannel: '', - managerDiscordChannel: '', - } - : prev, - ); - return; - } - - if ( - value.startsWith(DISCORD_DESTINATION_OPTION_PREFIX) - ) { - setIsEnteringCustomManagerChannel(false); setFormState((prev) => prev ? { ...prev, - managerSlackChannel: '', - managerDiscordChannel: value.slice( - DISCORD_DESTINATION_OPTION_PREFIX.length, - ), + managerSlackChannel: event.target.value, + managerDiscordChannel: '', } : prev, ); - return; - } - - const selectedChannel = managerChannelOptions.find( - (channel) => channel.id === value, - ); - - if (!selectedChannel) { - return; + }} + placeholder="Enter a private channel name or Slack channel ID" + autoCapitalize="off" + autoCorrect="off" + spellCheck={false} + /> + ) : null} +
+

+ Private channels may not appear in the list. Use the + manual option to paste a private channel name or raw Slack + channel ID. +

+ {showManagerSlackChannelWarning ? ( + + ) : null} + {fieldErrors.managerSlackChannel || + fieldErrors.managerDiscordChannel ? ( +

+ {fieldErrors.managerSlackChannel ?? + fieldErrors.managerDiscordChannel} +

+ ) : null} + {showManagerChannelMigrationNote ? ( + + + Some older automations still point at different Slack + channels. Pick the shared Manager Channel here to + migrate future manager-facing posts onto one + destination. + + + ) : null} +
+ saveAgent('managerChannel')} + onReset={() => { + resetAgent('managerChannel'); + if (managerChannelConfigured) { + setIsEditingManagerChannel(false); } - - setIsEnteringCustomManagerChannel(false); - setFormState((prev) => - prev - ? { - ...prev, - managerSlackChannel: selectedChannel.label, - managerDiscordChannel: '', - } - : prev, - ); }} - disabled={managerChannelSelectionDisabled} - > - - - {managerChannelSelectLabel} - - - - {managerChannelHasValue ? ( - <> - - Clear selection - - - - ) : null} - {slackChannelsQuery.isPending || - discordChannelsQuery.isPending ? ( - - Loading channels... - - ) : slackChannelsQuery.isError || - discordChannelsQuery.isError ? ( - - Could not load channels. Try refreshing. - - ) : managerChannelOptions.length > 0 || - managerDiscordChannelOptions.length > 0 ? ( - [ - ...managerChannelOptions, - ...managerDiscordChannelOptions, - ].map((channel) => ( - - {channel.label} - - )) - ) : ( - - No channels found. - - )} - - - Private or manual channel - - - - {managerChannelOptions.length > 0 || - managerDiscordChannelOptions.length > 0 ? ( + /> + {managerChannelConfigured && + isEditingManagerChannel && + !isDirty.managerChannel ? ( ) : null}
- {showCustomManagerChannelInput ? ( - { - setIsEnteringCustomManagerChannel(true); - setFormState((prev) => - prev - ? { - ...prev, - managerSlackChannel: event.target.value, - managerDiscordChannel: '', - } - : prev, - ); - }} - placeholder="Enter a private channel name or Slack channel ID" - autoCapitalize="off" - autoCorrect="off" - spellCheck={false} - /> - ) : null} -
-

- Private channels may not appear in the list. Use the manual - option to paste a private channel name or raw Slack channel - ID. + + ) : ( +

+ Posting manager-facing updates to{' '} +

- {showManagerSlackChannelWarning ? ( - - ) : null} - {fieldErrors.managerSlackChannel || - fieldErrors.managerDiscordChannel ? ( -

- {fieldErrors.managerSlackChannel ?? - fieldErrors.managerDiscordChannel} -

- ) : null} - {showManagerChannelMigrationNote ? ( - - - Some older automations still point at different Slack - channels. Pick the shared Manager Channel here to - migrate future manager-facing posts onto one - destination. - - - ) : null} -
- saveAgent('managerChannel')} - onReset={() => { - resetAgent('managerChannel'); - if (managerChannelConfigured) { - setIsEditingManagerChannel(false); - } - }} - /> - {managerChannelConfigured && - isEditingManagerChannel && - !isDirty.managerChannel ? ( - - ) : null} -
- - ) : ( -

- Posting manager-facing updates to{' '} + )} +

+ + + setAutomationOpen('managerStats', open)} + iconEnabled={iconEnabled.managerStats} + debugSection={renderDebugRunsSection('managerStats')} + runAction={ + -

- )} -

-
- - setAutomationOpen('managerStats', open)} - iconEnabled={iconEnabled.managerStats} - debugSection={renderDebugRunsSection('managerStats')} - runAction={ - - - - } - footer={ - saveAgent('managerStats')} - onReset={() => resetAgent('managerStats')} - /> - } - > -
-
- - setFormState((prev) => - prev - ? { - ...prev, - managerStatsFrequency: enabled ? 'weekly' : 'off', - } - : prev, - ) + + } + footer={ + saveAgent('managerStats')} + onReset={() => resetAgent('managerStats')} /> - + } + > +
+
+ + setFormState((prev) => + prev + ? { + ...prev, + managerStatsFrequency: enabled ? 'weekly' : 'off', + } + : prev, + ) + } + /> + +
+ + {managerStatsIsEnabled ? ( +
+ {renderSlackDestinationField({ + field: 'managerStatsSlackChannel', + inputId: 'manager-stats-slack-channel', + label: 'Post summaries to this Slack channel', + helperText: + 'Choose where Roomote should post the Friday manager digest.', + savedChannelId: + settingsQuery.data?.settings + .managerStatsSlackChannelId ?? null, + savedDiscordChannelId: + settingsQuery.data?.settings + .managerStatsDiscordChannelId ?? null, + warningChannelId: + slackChannelAccessWarnings.managerStatsSlackChannel, + })} + +

+ Posts a weekly summary on Fridays. +

+
+ ) : null}
+ - {managerStatsIsEnabled ? ( -
- {renderSlackDestinationField({ - field: 'managerStatsSlackChannel', - inputId: 'manager-stats-slack-channel', - label: 'Post summaries to this Slack channel', - helperText: - 'Choose where Roomote should post the Friday manager digest.', - savedChannelId: - settingsQuery.data?.settings.managerStatsSlackChannelId ?? - null, - savedDiscordChannelId: - settingsQuery.data?.settings - .managerStatsDiscordChannelId ?? null, - warningChannelId: - slackChannelAccessWarnings.managerStatsSlackChannel, - })} - -

- Posts a weekly summary on Fridays. -

-
- ) : null} -
- - - setAutomationOpen('sentryTriage', open)} - iconEnabled={iconEnabled.sentryTriage} - debugSection={renderDebugRunsSection('sentryTriage')} - runAction={ - - - - } - footer={ - saveAgent('sentryTriage')} - onReset={() => resetAgent('sentryTriage')} - /> - } - > -
- { + const frequency = value as SentryTriageFrequency; + + if ( + !canSelectSentryTriageFrequency({ + sentryConnected: sentryConnected, + frequency, + }) + ) { + toast.error( + 'Configure Sentry in Settings > Integrations before enabling Triage Sentry Issues.', + ); + return; + } - setFormState((prev) => - prev - ? { - ...prev, - sentryTriageFrequency: frequency, - } - : prev, - ); - }} - > - + prev + ? { + ...prev, + sentryTriageFrequency: frequency, + } + : prev, + ); + }} > - - - - {SENTRY_TRIAGE_FREQUENCY_OPTIONS.map((option) => ( - - {option.label} - - ))} - - - - {!sentryConnected ? ( - - - Connect Sentry first - -
- - Connect the workspace Sentry integration before enabling - scheduled Sentry triage. - - -
-
-
- ) : null} - - {sentryTriageIsEnabled ? ( -
- {renderSlackDestinationField({ - field: 'sentryTriageSlackChannel', - inputId: 'sentry-triage-slack-channel', - label: 'Post follow-up work to this Slack channel', - helperText: - 'Choose where Roomote should post actionable Sentry follow-up work.', - savedChannelId: - settingsQuery.data?.settings.sentryTriageSlackChannelId ?? - null, - savedDiscordChannelId: - settingsQuery.data?.settings - .sentryTriageDiscordChannelId ?? null, - warningChannelId: - slackChannelAccessWarnings.sentryTriageSlackChannel, - })} - -

- Requires Sentry to be configured in Settings > - Integrations. -

+ + + + + {SENTRY_TRIAGE_FREQUENCY_OPTIONS.map((option) => ( + + {option.label} + + ))} + + + + {!sentryConnected ? ( + + + Connect Sentry first + +
+ + Connect the workspace Sentry integration before + enabling scheduled Sentry triage. + + +
+
+
+ ) : null} + + {sentryTriageIsEnabled ? ( +
+ {renderSlackDestinationField({ + field: 'sentryTriageSlackChannel', + inputId: 'sentry-triage-slack-channel', + label: 'Post follow-up work to this Slack channel', + helperText: + 'Choose where Roomote should post actionable Sentry follow-up work.', + savedChannelId: + settingsQuery.data?.settings + .sentryTriageSlackChannelId ?? null, + savedDiscordChannelId: + settingsQuery.data?.settings + .sentryTriageDiscordChannelId ?? null, + warningChannelId: + slackChannelAccessWarnings.sentryTriageSlackChannel, + })} -
- -