From b667644100a3e0831b0f51ceab853be3e29703bd Mon Sep 17 00:00:00 2001 From: "@mrubens" <2600+mrubens@users.noreply.github.com> Date: Sun, 16 Aug 2026 21:49:22 +0000 Subject: [PATCH 1/4] feat: add per-user Slack fast mode default --- .env.production.example | 2 + .../slack/__tests__/fast-agent.test.ts | 41 +++++++++ .../src/handlers/slack/events/fast-agent.ts | 16 ++++ .../handlers/slack/events/message-entry.ts | 13 ++- .../slack/helpers/user-mapping.test.ts | 4 + .../handlers/slack/helpers/user-mapping.ts | 9 +- apps/docs/environment-variables.mdx | 1 + .../settings/UserPreferencesSection.test.tsx | 83 ++++++++++++++----- .../settings/UserPreferencesSection.tsx | 39 ++++++++- .../pages/PersonalSettingsPage.test.tsx | 2 + .../settings/pages/PersonalSettingsPage.tsx | 6 +- .../pages/PersonalSettingsRoute.test.tsx | 14 +++- .../settings/pages/PersonalSettingsRoute.tsx | 3 + .../usePersonalPreferences.client.test.tsx | 1 + apps/web/src/hooks/usePersonalPreferences.ts | 10 +++ .../src/trpc/commands/preferences/index.ts | 19 +++++ .../preferences/personal-preferences.test.ts | 54 ++++++++++++ apps/web/src/trpc/routers/_app.ts | 4 +- apps/web/src/types/preferences.ts | 2 + docker-compose.production.yml | 1 + docker-compose.self-host.yml | 1 + packages/env/src/__tests__/index.test.ts | 16 ++++ packages/env/src/index.ts | 2 + 23 files changed, 313 insertions(+), 30 deletions(-) diff --git a/.env.production.example b/.env.production.example index 5a352cad1..934c9fcd5 100644 --- a/.env.production.example +++ b/.env.production.example @@ -154,6 +154,8 @@ DEFAULT_COMPUTE_PROVIDER=docker # Set to true to prevent curated integrations from being configured or used. # Existing connections remain stored and become available again once unset. # R_CURATED_INTEGRATIONS_DISABLED=true +# Set to true to let users default their Slack messages to fast mode. +# R_SLACK_FAST_MODE_SETTING_ENABLED=true # SLACK_APP_ID= # R_SLACK_SIGNING_SECRET= # R_TELEGRAM_BOT_TOKEN= diff --git a/apps/api/src/handlers/slack/__tests__/fast-agent.test.ts b/apps/api/src/handlers/slack/__tests__/fast-agent.test.ts index 04f5e01b7..ce1e7f55d 100644 --- a/apps/api/src/handlers/slack/__tests__/fast-agent.test.ts +++ b/apps/api/src/handlers/slack/__tests__/fast-agent.test.ts @@ -1,6 +1,7 @@ import { extractFastQuestion, isFastCommandInvocation, + resolveFastAgentEntryMode, stripLeadingFastCommandMention, } from '../events/fast-agent'; @@ -26,4 +27,44 @@ describe('Slack fast-agent helpers', () => { expect(extractFastQuestion(' ', true)).toBeNull(); expect(extractFastQuestion('Good, tired')).toBeNull(); }); + + it('keeps ordinary messages on standard routing when the deployment flag is disabled', () => { + expect( + resolveFastAgentEntryMode({ + text: 'please fix this', + deploymentSettingEnabled: false, + userDefaultEnabled: true, + }), + ).toBeNull(); + }); + + it('keeps ordinary messages on standard routing when the user setting is off', () => { + expect( + resolveFastAgentEntryMode({ + text: 'please fix this', + deploymentSettingEnabled: true, + userDefaultEnabled: false, + }), + ).toBeNull(); + }); + + it('defaults ordinary messages to fast mode when both settings are enabled', () => { + expect( + resolveFastAgentEntryMode({ + text: 'please fix this', + deploymentSettingEnabled: true, + userDefaultEnabled: true, + }), + ).toBe('default'); + }); + + it('preserves explicit !fast routing regardless of the user setting', () => { + expect( + resolveFastAgentEntryMode({ + text: '!fast please fix this', + deploymentSettingEnabled: true, + userDefaultEnabled: true, + }), + ).toBe('explicit'); + }); }); diff --git a/apps/api/src/handlers/slack/events/fast-agent.ts b/apps/api/src/handlers/slack/events/fast-agent.ts index 1024d477b..04f49efc4 100644 --- a/apps/api/src/handlers/slack/events/fast-agent.ts +++ b/apps/api/src/handlers/slack/events/fast-agent.ts @@ -27,6 +27,22 @@ export function isBareFastCommandInvocation(text: string): boolean { return /^!fast(?:\s|$)/i.test(text.trimStart()); } +export type FastAgentEntryMode = 'explicit' | 'default'; + +export function resolveFastAgentEntryMode(params: { + text: string; + deploymentSettingEnabled: boolean; + userDefaultEnabled: boolean; +}): FastAgentEntryMode | null { + if (isFastCommandInvocation(params.text)) { + return 'explicit'; + } + + return params.deploymentSettingEnabled && params.userDefaultEnabled + ? 'default' + : null; +} + export function extractFastQuestion( mentionStrippedText: string, continuation = false, diff --git a/apps/api/src/handlers/slack/events/message-entry.ts b/apps/api/src/handlers/slack/events/message-entry.ts index b9e2848f2..c0b2930b2 100644 --- a/apps/api/src/handlers/slack/events/message-entry.ts +++ b/apps/api/src/handlers/slack/events/message-entry.ts @@ -55,8 +55,8 @@ import type { AutomatedSlackAppMentionEvent } from '../types.js'; import { processActiveRunMessage } from './active-run.js'; import { isBareFastCommandInvocation, - isFastCommandInvocation, processFastAgentMessage, + resolveFastAgentEntryMode, } from './fast-agent.js'; import { createFastAgentTaskLauncher } from './fast-agent-task-launcher.js'; import { processSnapshotResume } from './snapshot-resume.js'; @@ -1687,7 +1687,15 @@ async function handleSlackEntryEvent(params: { activeTaskId: activeRun?.taskId, }); - if (isFastCommandInvocation(event.text)) { + const fastAgentEntryMode = resolveFastAgentEntryMode({ + text: event.text, + deploymentSettingEnabled: Env.R_SLACK_FAST_MODE_SETTING_ENABLED === true, + userDefaultEnabled: + userMapping.slackFastModeDefault && + !isRemovedEvalCommandInvocation(event.text), + }); + + if (fastAgentEntryMode) { startFastAgentResponse({ event, slackInstallation, @@ -1696,6 +1704,7 @@ async function handleSlackEntryEvent(params: { userId: userMapping.userId, teamId, activeTaskId: activeRun?.taskId ?? null, + continuation: fastAgentEntryMode === 'default', processingReactionName: ackEmoji, errorLogPrefix: `❌ Background fast-agent response failed for thread ${threadId}:`, }); diff --git a/apps/api/src/handlers/slack/helpers/user-mapping.test.ts b/apps/api/src/handlers/slack/helpers/user-mapping.test.ts index b66f5fb43..af6021f79 100644 --- a/apps/api/src/handlers/slack/helpers/user-mapping.test.ts +++ b/apps/api/src/handlers/slack/helpers/user-mapping.test.ts @@ -24,6 +24,7 @@ vi.mock('@roomote/db/server', () => ({ users: { id: 'users.id', deletedAt: 'users.deletedAt', + metadata: 'users.metadata', }, })); @@ -51,6 +52,7 @@ describe('lookupSlackUserMapping', () => { updatedAt, matchedUserId: 'user-1', userDeletedAt: null, + userMetadata: { slack_fast_mode_default: true }, }, ]); @@ -66,6 +68,7 @@ describe('lookupSlackUserMapping', () => { userId: 'user-1', createdAt, updatedAt, + slackFastModeDefault: true, }, hasInactiveMapping: false, }); @@ -82,6 +85,7 @@ describe('lookupSlackUserMapping', () => { updatedAt: new Date('2024-01-02T00:00:00.000Z'), matchedUserId: 'user-1', userDeletedAt: new Date('2024-02-01T00:00:00.000Z'), + userMetadata: {}, }, ]); diff --git a/apps/api/src/handlers/slack/helpers/user-mapping.ts b/apps/api/src/handlers/slack/helpers/user-mapping.ts index 4aa738842..2ed0045f1 100644 --- a/apps/api/src/handlers/slack/helpers/user-mapping.ts +++ b/apps/api/src/handlers/slack/helpers/user-mapping.ts @@ -8,7 +8,7 @@ import { } from '@roomote/db/server'; type SlackUserMappingLookup = { - activeMapping: SlackUserMapping | null; + activeMapping: (SlackUserMapping & { slackFastModeDefault: boolean }) | null; hasInactiveMapping: boolean; }; @@ -26,6 +26,7 @@ export async function lookupSlackUserMapping(params: { updatedAt: slackUserMappings.updatedAt, matchedUserId: users.id, userDeletedAt: users.deletedAt, + userMetadata: users.metadata, }) .from(slackUserMappings) .leftJoin(users, eq(users.id, slackUserMappings.userId)) @@ -59,6 +60,12 @@ export async function lookupSlackUserMapping(params: { userId: row.userId, createdAt: row.createdAt, updatedAt: row.updatedAt, + slackFastModeDefault: + typeof row.userMetadata === 'object' && + row.userMetadata !== null && + !Array.isArray(row.userMetadata) && + (row.userMetadata as Record) + .slack_fast_mode_default === true, }, hasInactiveMapping: false, }; diff --git a/apps/docs/environment-variables.mdx b/apps/docs/environment-variables.mdx index eadbe990d..e5a888b9b 100644 --- a/apps/docs/environment-variables.mdx +++ b/apps/docs/environment-variables.mdx @@ -127,6 +127,7 @@ as per-task auth tokens or workspace paths. | `ROOMOTE_FORCE_TELEMETRY` | Development only | Force-enables telemetry in development or preview environments when a Ping endpoint is explicitly configured. | | `R_CLOUD_ENABLED` | Roomote Cloud only | Deployment-managed switch for Roomote Cloud behavior, including required anonymous analytics and Cloud support integrations. Do not set this for self-hosted deployments. | | `R_CURATED_INTEGRATIONS_DISABLED` | Optional | Operator policy for the curated **Settings > Integrations** catalog, which is enabled by default. Set to `true` and restart Roomote to prevent those integrations from being configured or used. Existing connections remain stored while disabled and become available again once the value is unset. Communications, source-control, inference, sandbox providers, and environment-defined MCP servers are unaffected. | +| `R_SLACK_FAST_MODE_SETTING_ENABLED` | Optional | Set to `true` and restart Roomote to expose a personal setting that defaults a user's Slack messages to fast mode without requiring `!fast`. The setting is hidden and unavailable when this flag is unset. | | `R_GITHUB_APP_SLUG` | GitHub setup | GitHub App slug used by server-rendered setup and GitHub integration flows. | | `SETUP_TOKEN` | Required (non-local) | One-time bootstrap token that admits the first admin at `/setup`. Required on every non-local deployment — tokenless bootstrap is allowed only when `NODE_ENV` is not `production` and `R_APP_ENV` is `development`, so anything running with `NODE_ENV=production` needs it. Without it, first-admin bootstrap stays closed so nobody can claim the founding-admin slot by reaching the URL first. Optional only in local development. | | `DASHBOARD_PASSWORD` | Production | Local fallback/admin password value used by the deployment. Generate a strong secret. | diff --git a/apps/web/src/components/settings/UserPreferencesSection.test.tsx b/apps/web/src/components/settings/UserPreferencesSection.test.tsx index ef268a3c7..cebbeedc4 100644 --- a/apps/web/src/components/settings/UserPreferencesSection.test.tsx +++ b/apps/web/src/components/settings/UserPreferencesSection.test.tsx @@ -3,28 +3,37 @@ import { fireEvent, render, screen, within } from '@testing-library/react'; type PersonalColorTheme = 'light' | 'dark' | 'system'; -const { colorThemeState, mindReaderModeState, narrationModeState } = vi.hoisted( - () => ({ - colorThemeState: { - colorTheme: 'system' as PersonalColorTheme, - isLoading: false, - isUpdating: false, - setColorTheme: vi.fn(), - }, - mindReaderModeState: { - enabled: false, - isLoading: false, - isUpdating: false, - setEnabled: vi.fn(), - }, - narrationModeState: { - enabled: false, - isLoading: false, - isUpdating: false, - setEnabled: vi.fn(), - }, - }), -); +const { + colorThemeState, + mindReaderModeState, + narrationModeState, + personalPreferencesState, +} = vi.hoisted(() => ({ + colorThemeState: { + colorTheme: 'system' as PersonalColorTheme, + isLoading: false, + isUpdating: false, + setColorTheme: vi.fn(), + }, + mindReaderModeState: { + enabled: false, + isLoading: false, + isUpdating: false, + setEnabled: vi.fn(), + }, + narrationModeState: { + enabled: false, + isLoading: false, + isUpdating: false, + setEnabled: vi.fn(), + }, + personalPreferencesState: { + preferences: { slackFastModeDefault: false }, + isLoading: false, + isUpdating: false, + setPreferences: vi.fn(), + }, +})); vi.mock('@/hooks/useColorTheme', () => ({ useColorTheme: () => colorThemeState, @@ -38,6 +47,10 @@ vi.mock('@/hooks/useMindReaderMode', () => ({ useMindReaderMode: () => mindReaderModeState, })); +vi.mock('@/hooks/usePersonalPreferences', () => ({ + usePersonalPreferences: () => personalPreferencesState, +})); + vi.mock('@/components/system', () => ({ Label: ({ children, @@ -123,6 +136,9 @@ describe('UserPreferencesSection', () => { narrationModeState.enabled = false; narrationModeState.isLoading = false; narrationModeState.isUpdating = false; + personalPreferencesState.preferences.slackFastModeDefault = false; + personalPreferencesState.isLoading = false; + personalPreferencesState.isUpdating = false; }); it('renders user preference controls with the current state', () => { @@ -204,4 +220,27 @@ describe('UserPreferencesSection', () => { 'system', ); }); + + it('hides the Slack fast mode default when it is unavailable', () => { + render(); + + expect( + screen.queryByLabelText('Toggle Slack fast mode default'), + ).not.toBeInTheDocument(); + }); + + it('updates the Slack fast mode default when it is available', () => { + personalPreferencesState.preferences.slackFastModeDefault = true; + + render(); + + const toggle = screen.getByLabelText('Toggle Slack fast mode default'); + expect(toggle).toBeChecked(); + + fireEvent.click(toggle); + + expect(personalPreferencesState.setPreferences).toHaveBeenCalledWith({ + slackFastModeDefault: false, + }); + }); }); diff --git a/apps/web/src/components/settings/UserPreferencesSection.tsx b/apps/web/src/components/settings/UserPreferencesSection.tsx index 1f54c82fb..927b4089d 100644 --- a/apps/web/src/components/settings/UserPreferencesSection.tsx +++ b/apps/web/src/components/settings/UserPreferencesSection.tsx @@ -3,6 +3,7 @@ import { useColorTheme } from '@/hooks/useColorTheme'; import { useMindReaderMode } from '@/hooks/useMindReaderMode'; import { useNarrationMode } from '@/hooks/useNarrationMode'; +import { usePersonalPreferences } from '@/hooks/usePersonalPreferences'; import type { PersonalColorTheme } from '@/types/preferences'; import { @@ -27,7 +28,11 @@ const COLOR_THEME_OPTIONS: ReadonlyArray<{ { label: 'Auto', value: 'system' }, ]; -export function UserPreferencesSection() { +export function UserPreferencesSection({ + slackFastModeDefaultAvailable = false, +}: { + slackFastModeDefaultAvailable?: boolean; +}) { const { colorTheme, isLoading: isThemeLoading, @@ -46,6 +51,15 @@ export function UserPreferencesSection() { isUpdating: isNarrationModeUpdating, setEnabled: setNarrationModeEnabled, } = useNarrationMode(); + const { + preferences, + isLoading: isSlackFastModeDefaultLoading, + isUpdating: isSlackFastModeDefaultUpdating, + setPreferences, + } = usePersonalPreferences({ + enabled: slackFastModeDefaultAvailable, + errorMessage: 'Failed to update the Slack fast mode default.', + }); const isThemeDisabled = isThemeLoading || isThemeUpdating; return ( @@ -113,6 +127,29 @@ export function UserPreferencesSection() {

+ + {slackFastModeDefaultAvailable ? ( +
+ + setPreferences({ slackFastModeDefault: enabled }) + } + /> +
+

+ Default Slack messages to fast mode +

+

+ Use fast mode for your Slack messages without adding !fast. +

+
+
+ ) : null} ); diff --git a/apps/web/src/components/settings/pages/PersonalSettingsPage.test.tsx b/apps/web/src/components/settings/pages/PersonalSettingsPage.test.tsx index d7bb3aa31..bfdf2b04f 100644 --- a/apps/web/src/components/settings/pages/PersonalSettingsPage.test.tsx +++ b/apps/web/src/components/settings/pages/PersonalSettingsPage.test.tsx @@ -53,6 +53,7 @@ describe('PersonalSettingsPage', () => { , ); @@ -70,6 +71,7 @@ describe('PersonalSettingsPage', () => { , ); diff --git a/apps/web/src/components/settings/pages/PersonalSettingsPage.tsx b/apps/web/src/components/settings/pages/PersonalSettingsPage.tsx index a7dbe23f4..d3f25e00e 100644 --- a/apps/web/src/components/settings/pages/PersonalSettingsPage.tsx +++ b/apps/web/src/components/settings/pages/PersonalSettingsPage.tsx @@ -13,10 +13,12 @@ export function PersonalSettingsPage({ profile, canChangePassword, canSetPassword, + slackFastModeDefaultAvailable, }: { profile: UserProfileSectionProfile; canChangePassword: boolean; canSetPassword: boolean; + slackFastModeDefaultAvailable: boolean; }) { return ( @@ -27,7 +29,9 @@ export function PersonalSettingsPage({ {canChangePassword || canSetPassword ? ( ) : null} - + ); diff --git a/apps/web/src/components/settings/pages/PersonalSettingsRoute.test.tsx b/apps/web/src/components/settings/pages/PersonalSettingsRoute.test.tsx index 28dbad673..6ae61ac89 100644 --- a/apps/web/src/components/settings/pages/PersonalSettingsRoute.test.tsx +++ b/apps/web/src/components/settings/pages/PersonalSettingsRoute.test.tsx @@ -1,10 +1,15 @@ import { render } from '@testing-library/react'; let accountCapabilities: - | { canChangePassword: boolean; canSetPassword: boolean } + | { + canChangePassword: boolean; + canSetPassword: boolean; + slackFastModeDefaultAvailable: boolean; + } | undefined = { canChangePassword: true, canSetPassword: false, + slackFastModeDefaultAvailable: true, }; const { personalSettingsPageMock } = vi.hoisted(() => ({ @@ -41,7 +46,11 @@ import { PersonalSettingsRoute } from './PersonalSettingsRoute'; describe('PersonalSettingsRoute', () => { beforeEach(() => { - accountCapabilities = { canChangePassword: true, canSetPassword: false }; + accountCapabilities = { + canChangePassword: true, + canSetPassword: false, + slackFastModeDefaultAvailable: true, + }; personalSettingsPageMock.mockClear(); }); @@ -52,6 +61,7 @@ describe('PersonalSettingsRoute', () => { { canChangePassword: true, canSetPassword: false, + slackFastModeDefaultAvailable: true, profile: { email: 'ada@example.com', imageUrl: 'https://example.com/ada.png', diff --git a/apps/web/src/components/settings/pages/PersonalSettingsRoute.tsx b/apps/web/src/components/settings/pages/PersonalSettingsRoute.tsx index 58487d136..f89cfcf41 100644 --- a/apps/web/src/components/settings/pages/PersonalSettingsRoute.tsx +++ b/apps/web/src/components/settings/pages/PersonalSettingsRoute.tsx @@ -18,6 +18,9 @@ export function PersonalSettingsRoute() { { colorTheme: 'system', mindReaderMode: false, narrationMode: false, + slackFastModeDefault: false, }); }); diff --git a/apps/web/src/hooks/usePersonalPreferences.ts b/apps/web/src/hooks/usePersonalPreferences.ts index 32a812ee1..c85ad891f 100644 --- a/apps/web/src/hooks/usePersonalPreferences.ts +++ b/apps/web/src/hooks/usePersonalPreferences.ts @@ -51,6 +51,10 @@ function mergeResultForUpdatedFields( updates.narrationMode === undefined ? mergedPreferences.narrationMode : result.narrationMode, + slackFastModeDefault: + updates.slackFastModeDefault === undefined + ? mergedPreferences.slackFastModeDefault + : result.slackFastModeDefault, }; } @@ -81,6 +85,12 @@ function rollbackUpdatedFields( mergedPreferences.narrationMode === optimisticPreferences.narrationMode ? previousPreferences.narrationMode : mergedPreferences.narrationMode, + slackFastModeDefault: + updates.slackFastModeDefault !== undefined && + mergedPreferences.slackFastModeDefault === + optimisticPreferences.slackFastModeDefault + ? previousPreferences.slackFastModeDefault + : mergedPreferences.slackFastModeDefault, }; } diff --git a/apps/web/src/trpc/commands/preferences/index.ts b/apps/web/src/trpc/commands/preferences/index.ts index 226187af2..5e1825941 100644 --- a/apps/web/src/trpc/commands/preferences/index.ts +++ b/apps/web/src/trpc/commands/preferences/index.ts @@ -3,6 +3,7 @@ import { headers } from 'next/headers'; import type { UserAuthSuccess } from '@/types'; import { getAuth } from '@/lib/server/auth'; +import { Env } from '@/lib/server/env'; import { userHasCredentialAccount } from '@/lib/server/user-management'; import { DEFAULT_PERSONAL_PREFERENCES, @@ -36,6 +37,9 @@ function normalizePersonalPreferences( typeof metadata.narration_mode === 'boolean' ? metadata.narration_mode : DEFAULT_PERSONAL_PREFERENCES.narrationMode, + slackFastModeDefault: + Env.R_SLACK_FAST_MODE_SETTING_ENABLED === true && + metadata.slack_fast_mode_default === true, }; } @@ -62,6 +66,8 @@ export async function getPersonalAccountCapabilitiesCommand( return { canChangePassword: hasCredentialAccount, canSetPassword: !hasCredentialAccount, + slackFastModeDefaultAvailable: + Env.R_SLACK_FAST_MODE_SETTING_ENABLED === true, }; } @@ -113,6 +119,15 @@ export async function updatePersonalPreferencesCommand( ): Promise { const nextMetadataRecord: UserMetadataRecord = {}; + if ( + input.slackFastModeDefault !== undefined && + Env.R_SLACK_FAST_MODE_SETTING_ENABLED !== true + ) { + throw new Error( + 'The Slack fast mode default setting is not enabled for this deployment.', + ); + } + if (input.colorTheme !== undefined) { nextMetadataRecord.color_theme = input.colorTheme; } @@ -125,6 +140,10 @@ export async function updatePersonalPreferencesCommand( nextMetadataRecord.narration_mode = input.narrationMode; } + if (input.slackFastModeDefault !== undefined) { + nextMetadataRecord.slack_fast_mode_default = input.slackFastModeDefault; + } + if (Object.keys(nextMetadataRecord).length === 0) { return getPersonalPreferencesCommand(auth); } diff --git a/apps/web/src/trpc/commands/preferences/personal-preferences.test.ts b/apps/web/src/trpc/commands/preferences/personal-preferences.test.ts index ce37848b8..1cdbcf6ef 100644 --- a/apps/web/src/trpc/commands/preferences/personal-preferences.test.ts +++ b/apps/web/src/trpc/commands/preferences/personal-preferences.test.ts @@ -2,6 +2,12 @@ import { db, eq, userFactory, users } from '@roomote/db/server'; import type { UserAuthSuccess } from '@/types'; +const { mockEnv } = vi.hoisted(() => ({ + mockEnv: { R_SLACK_FAST_MODE_SETTING_ENABLED: false }, +})); + +vi.mock('@/lib/server/env', () => ({ Env: mockEnv })); + import { getPersonalPreferencesCommand, updatePersonalPreferencesCommand, @@ -12,6 +18,10 @@ function buildAuth(userId: string) { } describe('personal preferences', () => { + beforeEach(() => { + mockEnv.R_SLACK_FAST_MODE_SETTING_ENABLED = false; + }); + it('defaults mind reader mode to disabled', async () => { const user = await userFactory.create(); @@ -60,4 +70,48 @@ describe('personal preferences', () => { }), ); }); + + it('rejects Slack fast mode updates when the deployment setting is disabled', async () => { + const user = await userFactory.create(); + + await expect( + updatePersonalPreferencesCommand(buildAuth(user.id), { + slackFastModeDefault: true, + }), + ).rejects.toThrow( + 'The Slack fast mode default setting is not enabled for this deployment.', + ); + }); + + it('does not expose a stored Slack fast mode default when the deployment setting is disabled', async () => { + const user = await userFactory.create({ + metadata: { slack_fast_mode_default: true }, + }); + + await expect( + getPersonalPreferencesCommand(buildAuth(user.id)), + ).resolves.toEqual( + expect.objectContaining({ slackFastModeDefault: false }), + ); + }); + + it('persists the Slack fast mode default when the deployment setting is enabled', async () => { + mockEnv.R_SLACK_FAST_MODE_SETTING_ENABLED = true; + const user = await userFactory.create(); + + await expect( + updatePersonalPreferencesCommand(buildAuth(user.id), { + slackFastModeDefault: true, + }), + ).resolves.toEqual(expect.objectContaining({ slackFastModeDefault: true })); + + const storedUser = await db.query.users.findFirst({ + where: eq(users.id, user.id), + columns: { metadata: true }, + }); + + expect(storedUser?.metadata).toEqual( + expect.objectContaining({ slack_fast_mode_default: true }), + ); + }); }); diff --git a/apps/web/src/trpc/routers/_app.ts b/apps/web/src/trpc/routers/_app.ts index 39a09010a..eda088036 100644 --- a/apps/web/src/trpc/routers/_app.ts +++ b/apps/web/src/trpc/routers/_app.ts @@ -1432,12 +1432,14 @@ export const appRouter = createRouter({ colorTheme: z.enum(PERSONAL_COLOR_THEMES).optional(), mindReaderMode: z.boolean().optional(), narrationMode: z.boolean().optional(), + slackFastModeDefault: z.boolean().optional(), }) .refine( (input) => input.colorTheme !== undefined || input.mindReaderMode !== undefined || - input.narrationMode !== undefined, + input.narrationMode !== undefined || + input.slackFastModeDefault !== undefined, { message: 'Expected at least one personal preference to update.', }, diff --git a/apps/web/src/types/preferences.ts b/apps/web/src/types/preferences.ts index dbcf508e0..e0d1db788 100644 --- a/apps/web/src/types/preferences.ts +++ b/apps/web/src/types/preferences.ts @@ -16,6 +16,7 @@ export interface PersonalPreferences { colorTheme: PersonalColorTheme; mindReaderMode: boolean; narrationMode: boolean; + slackFastModeDefault: boolean; } export type PersonalPreferencesUpdate = Partial; @@ -24,4 +25,5 @@ export const DEFAULT_PERSONAL_PREFERENCES: PersonalPreferences = { colorTheme: 'system', mindReaderMode: false, narrationMode: false, + slackFastModeDefault: false, }; diff --git a/docker-compose.production.yml b/docker-compose.production.yml index bdf31ef35..8aacfe159 100644 --- a/docker-compose.production.yml +++ b/docker-compose.production.yml @@ -23,6 +23,7 @@ x-roomote-production-env: &roomote-production-env R_PUBLIC_URL: https://${ROOMOTE_APP_DOMAIN:?ROOMOTE_APP_DOMAIN is required} R_INSTANCE_ID: ${R_INSTANCE_ID:-} R_CURATED_INTEGRATIONS_DISABLED: ${R_CURATED_INTEGRATIONS_DISABLED:-false} + R_SLACK_FAST_MODE_SETTING_ENABLED: ${R_SLACK_FAST_MODE_SETTING_ENABLED:-false} TRPC_URL: http://api:3001 PREVIEW_PROXY_BASE_URL: https://${ROOMOTE_PREVIEW_DOMAIN:?ROOMOTE_PREVIEW_DOMAIN is required} NEXT_PUBLIC_PREVIEW_PROXY_BASE_URL: https://${ROOMOTE_PREVIEW_DOMAIN:?ROOMOTE_PREVIEW_DOMAIN is required} diff --git a/docker-compose.self-host.yml b/docker-compose.self-host.yml index 59691603d..b507f98fe 100644 --- a/docker-compose.self-host.yml +++ b/docker-compose.self-host.yml @@ -22,6 +22,7 @@ x-roomote-env: &roomote-env R_APP_URL: ${R_PUBLIC_URL:-http://localhost:13000} R_INSTANCE_ID: ${R_INSTANCE_ID:-} R_CURATED_INTEGRATIONS_DISABLED: ${R_CURATED_INTEGRATIONS_DISABLED:-false} + R_SLACK_FAST_MODE_SETTING_ENABLED: ${R_SLACK_FAST_MODE_SETTING_ENABLED:-false} # The Brain: supplying this key gives the deployment shared memory that # agents consult. It powers the brain service's embeddings and is the # single activation signal — there is no Settings UI for it. diff --git a/packages/env/src/__tests__/index.test.ts b/packages/env/src/__tests__/index.test.ts index 1a36413f4..a72d0097a 100644 --- a/packages/env/src/__tests__/index.test.ts +++ b/packages/env/src/__tests__/index.test.ts @@ -226,6 +226,22 @@ describe('Env', () => { expect(areCuratedIntegrationsDisabled('0')).toBe(false); }); + it('keeps the Slack fast mode setting opt-in', () => { + const runtimeEnv = { ...process.env }; + delete runtimeEnv.SKIP_ENV_VALIDATION; + delete runtimeEnv.R_SLACK_FAST_MODE_SETTING_ENABLED; + + expect(createRoomoteEnv(runtimeEnv).R_SLACK_FAST_MODE_SETTING_ENABLED).toBe( + false, + ); + expect( + createRoomoteEnv({ + ...runtimeEnv, + R_SLACK_FAST_MODE_SETTING_ENABLED: 'true', + }).R_SLACK_FAST_MODE_SETTING_ENABLED, + ).toBe(true); + }); + it('accepts valid Ping instance IDs and rejects invalid ones', () => { const runtimeEnv = { ...process.env }; delete runtimeEnv.SKIP_ENV_VALIDATION; diff --git a/packages/env/src/index.ts b/packages/env/src/index.ts index 824bd3c8a..3bf0e2db4 100644 --- a/packages/env/src/index.ts +++ b/packages/env/src/index.ts @@ -124,6 +124,8 @@ const serverSchema = { // Roomote Cloud-only analytics and support integrations. These values are // intentionally not used by self-hosted deployments. R_CLOUD_ENABLED: optInBoolean(), + // Exposes the per-user setting that defaults Slack messages to fast mode. + R_SLACK_FAST_MODE_SETTING_ENABLED: optInBoolean(), // Operator policy for the curated Settings > Integrations catalog. Enabled // by default; operators opt out explicitly. Existing connections remain // stored but cannot be configured or used while disabled. From 596c13e803c7c247c09b79e3745e1555c6dcd1e9 Mon Sep 17 00:00:00 2001 From: "@mrubens" <2600+mrubens@users.noreply.github.com> Date: Sun, 16 Aug 2026 21:51:02 +0000 Subject: [PATCH 2/4] chore: keep fast entry mode internal --- apps/api/src/handlers/slack/events/fast-agent.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/api/src/handlers/slack/events/fast-agent.ts b/apps/api/src/handlers/slack/events/fast-agent.ts index 04f49efc4..1cb067bfa 100644 --- a/apps/api/src/handlers/slack/events/fast-agent.ts +++ b/apps/api/src/handlers/slack/events/fast-agent.ts @@ -27,7 +27,7 @@ export function isBareFastCommandInvocation(text: string): boolean { return /^!fast(?:\s|$)/i.test(text.trimStart()); } -export type FastAgentEntryMode = 'explicit' | 'default'; +type FastAgentEntryMode = 'explicit' | 'default'; export function resolveFastAgentEntryMode(params: { text: string; From f809b936647e6e658166d9d71da07ad1bb3bf885 Mon Sep 17 00:00:00 2001 From: "@mrubens" <2600+mrubens@users.noreply.github.com> Date: Sun, 16 Aug 2026 22:16:18 +0000 Subject: [PATCH 3/4] fix: honor Slack fast default in auto-start channels --- .../slack/__tests__/fast-agent.test.ts | 8 +- .../channel-auto-start-unlinked.test.ts | 77 ++++++++++++++++++- .../src/handlers/slack/events/fast-agent.ts | 4 +- .../handlers/slack/events/message-entry.ts | 24 ++++-- .../trpc/commands/preferences/index.test.ts | 2 + 5 files changed, 100 insertions(+), 15 deletions(-) diff --git a/apps/api/src/handlers/slack/__tests__/fast-agent.test.ts b/apps/api/src/handlers/slack/__tests__/fast-agent.test.ts index ce1e7f55d..c31141c74 100644 --- a/apps/api/src/handlers/slack/__tests__/fast-agent.test.ts +++ b/apps/api/src/handlers/slack/__tests__/fast-agent.test.ts @@ -31,7 +31,7 @@ describe('Slack fast-agent helpers', () => { it('keeps ordinary messages on standard routing when the deployment flag is disabled', () => { expect( resolveFastAgentEntryMode({ - text: 'please fix this', + explicitInvocation: false, deploymentSettingEnabled: false, userDefaultEnabled: true, }), @@ -41,7 +41,7 @@ describe('Slack fast-agent helpers', () => { it('keeps ordinary messages on standard routing when the user setting is off', () => { expect( resolveFastAgentEntryMode({ - text: 'please fix this', + explicitInvocation: false, deploymentSettingEnabled: true, userDefaultEnabled: false, }), @@ -51,7 +51,7 @@ describe('Slack fast-agent helpers', () => { it('defaults ordinary messages to fast mode when both settings are enabled', () => { expect( resolveFastAgentEntryMode({ - text: 'please fix this', + explicitInvocation: false, deploymentSettingEnabled: true, userDefaultEnabled: true, }), @@ -61,7 +61,7 @@ describe('Slack fast-agent helpers', () => { it('preserves explicit !fast routing regardless of the user setting', () => { expect( resolveFastAgentEntryMode({ - text: '!fast please fix this', + explicitInvocation: true, deploymentSettingEnabled: true, userDefaultEnabled: true, }), diff --git a/apps/api/src/handlers/slack/events/channel-auto-start-unlinked.test.ts b/apps/api/src/handlers/slack/events/channel-auto-start-unlinked.test.ts index 4b72eb055..aecb1430f 100644 --- a/apps/api/src/handlers/slack/events/channel-auto-start-unlinked.test.ts +++ b/apps/api/src/handlers/slack/events/channel-auto-start-unlinked.test.ts @@ -1,7 +1,15 @@ import { describe, expect, it, vi, beforeEach } from 'vitest'; -const { showConnectAccountMock } = vi.hoisted(() => ({ +const { + fastAgentMessageMock, + showConnectAccountMock, + startTaskMock, + userMappingRowsMock, +} = vi.hoisted(() => ({ + fastAgentMessageMock: vi.fn(), showConnectAccountMock: vi.fn(), + startTaskMock: vi.fn(), + userMappingRowsMock: vi.fn(), })); const { enrichSlackMessageEventMock, isRoomoteAuthoredSlackEventMock } = @@ -11,7 +19,11 @@ const { enrichSlackMessageEventMock, isRoomoteAuthoredSlackEventMock } = })); vi.mock('@roomote/env', () => ({ - Env: { TRPC_URL: null, R_APP_URL: 'http://localhost:3000' }, + Env: { + TRPC_URL: null, + R_APP_URL: 'http://localhost:3000', + R_SLACK_FAST_MODE_SETTING_ENABLED: true, + }, })); vi.mock('@roomote/cloud-agents/server', () => ({ @@ -31,7 +43,17 @@ vi.mock('../helpers/event-normalization.js', () => ({ vi.mock('@roomote/slack', async (importOriginal) => ({ ...(await importOriginal()), + resolveSlackReactionNames: vi.fn().mockResolvedValue({ + ackEmoji: 'eyes', + completionEmoji: 'white_check_mark', + }), showConnectAccount: showConnectAccountMock, + startAutoRoutedSlackTask: startTaskMock, +})); + +vi.mock('./fast-agent.js', async (importOriginal) => ({ + ...(await importOriginal()), + processFastAgentMessage: fastAgentMessageMock, })); const redisClientMock = { @@ -60,7 +82,7 @@ const userMappingSelectChain = () => ({ from: vi.fn(() => ({ leftJoin: vi.fn(() => ({ where: vi.fn(() => ({ - limit: vi.fn().mockResolvedValue([]), + limit: userMappingRowsMock, })), })), })), @@ -78,6 +100,8 @@ describe('channel auto-start unlinked author', () => { beforeEach(() => { vi.clearAllMocks(); showConnectAccountMock.mockResolvedValue(undefined); + fastAgentMessageMock.mockResolvedValue(undefined); + userMappingRowsMock.mockResolvedValue([]); }); it('prompts an unlinked human author to connect their account instead of silently skipping', async () => { @@ -114,4 +138,51 @@ describe('channel auto-start unlinked author', () => { expect.anything(), ); }, 30000); + + it('routes an opted-in linked author to fast mode before channel auto-start', async () => { + userMappingRowsMock.mockResolvedValue([ + { + id: 'mapping-1', + slackUserId: 'U456', + slackTeamId: 'T123', + userId: 'user-1', + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-01T00:00:00.000Z'), + matchedUserId: 'user-1', + userDeletedAt: null, + userMetadata: { slack_fast_mode_default: true }, + }, + ]); + const { handleMessageOrAppMentionEvent } = + await import('./message-entry.js'); + + await handleMessageOrAppMentionEvent({ + event: { + type: 'message', + channel: 'C123', + user: 'U456', + text: 'please look into this', + ts: '112.000', + channel_type: 'channel', + } as never, + context: { + slackInstallation: { teamId: 'T123', botUserId: 'UBOT' } as never, + slack: {} as never, + teamId: 'T123', + } as never, + }); + + expect(fastAgentMessageMock).toHaveBeenCalledWith( + expect.objectContaining({ + continuation: true, + event: expect.objectContaining({ + text: 'please look into this', + user: 'U456', + }), + teamId: 'T123', + userId: 'user-1', + }), + ); + expect(startTaskMock).not.toHaveBeenCalled(); + }, 30000); }); diff --git a/apps/api/src/handlers/slack/events/fast-agent.ts b/apps/api/src/handlers/slack/events/fast-agent.ts index 1cb067bfa..8e9c88ff0 100644 --- a/apps/api/src/handlers/slack/events/fast-agent.ts +++ b/apps/api/src/handlers/slack/events/fast-agent.ts @@ -30,11 +30,11 @@ export function isBareFastCommandInvocation(text: string): boolean { type FastAgentEntryMode = 'explicit' | 'default'; export function resolveFastAgentEntryMode(params: { - text: string; + explicitInvocation: boolean; deploymentSettingEnabled: boolean; userDefaultEnabled: boolean; }): FastAgentEntryMode | null { - if (isFastCommandInvocation(params.text)) { + if (params.explicitInvocation) { return 'explicit'; } diff --git a/apps/api/src/handlers/slack/events/message-entry.ts b/apps/api/src/handlers/slack/events/message-entry.ts index c0b2930b2..4daa20016 100644 --- a/apps/api/src/handlers/slack/events/message-entry.ts +++ b/apps/api/src/handlers/slack/events/message-entry.ts @@ -55,6 +55,7 @@ import type { AutomatedSlackAppMentionEvent } from '../types.js'; import { processActiveRunMessage } from './active-run.js'; import { isBareFastCommandInvocation, + isFastCommandInvocation, processFastAgentMessage, resolveFastAgentEntryMode, } from './fast-agent.js'; @@ -1223,11 +1224,21 @@ async function maybeHandleChannelAutoStart(params: { const { ackEmoji } = await resolveSlackReactionNames(); - if ( - userMapping && - typeof channelAutoStartEvent.user === 'string' && - isBareFastCommandInvocation(channelAutoStartEvent.text) - ) { + const fastAgentEntryMode = + userMapping && typeof channelAutoStartEvent.user === 'string' + ? resolveFastAgentEntryMode({ + explicitInvocation: isBareFastCommandInvocation( + channelAutoStartEvent.text, + ), + deploymentSettingEnabled: + Env.R_SLACK_FAST_MODE_SETTING_ENABLED === true, + userDefaultEnabled: + userMapping.slackFastModeDefault && + !isRemovedEvalCommandInvocation(channelAutoStartEvent.text), + }) + : null; + + if (fastAgentEntryMode && userMapping) { startFastAgentResponse({ event: { ...channelAutoStartEvent, user: channelAutoStartEvent.user }, slackInstallation: context.slackInstallation, @@ -1236,6 +1247,7 @@ async function maybeHandleChannelAutoStart(params: { userId: userMapping.userId, teamId: context.teamId, usageText: 'Use `!fast ` in this channel.', + continuation: fastAgentEntryMode === 'default', processingReactionName: ackEmoji, errorLogPrefix: `❌ Background fast-agent response failed for auto-start thread ${channelAutoStartEvent.ts}:`, }); @@ -1688,7 +1700,7 @@ async function handleSlackEntryEvent(params: { }); const fastAgentEntryMode = resolveFastAgentEntryMode({ - text: event.text, + explicitInvocation: isFastCommandInvocation(event.text), deploymentSettingEnabled: Env.R_SLACK_FAST_MODE_SETTING_ENABLED === true, userDefaultEnabled: userMapping.slackFastModeDefault && diff --git a/apps/web/src/trpc/commands/preferences/index.test.ts b/apps/web/src/trpc/commands/preferences/index.test.ts index 20c2973af..c36610270 100644 --- a/apps/web/src/trpc/commands/preferences/index.test.ts +++ b/apps/web/src/trpc/commands/preferences/index.test.ts @@ -41,6 +41,7 @@ describe('personal account capabilities', () => { await expect(getPersonalAccountCapabilitiesCommand(auth)).resolves.toEqual({ canChangePassword: false, canSetPassword: true, + slackFastModeDefaultAvailable: false, }); }); @@ -50,6 +51,7 @@ describe('personal account capabilities', () => { await expect(getPersonalAccountCapabilitiesCommand(auth)).resolves.toEqual({ canChangePassword: true, canSetPassword: false, + slackFastModeDefaultAvailable: false, }); }); From 11af0785519cd7dc9a1c1420add2ad984edbbbbd Mon Sep 17 00:00:00 2001 From: "@mrubens" <2600+mrubens@users.noreply.github.com> Date: Sun, 16 Aug 2026 23:18:08 +0000 Subject: [PATCH 4/4] refactor: generalize communications fast default naming --- .env.production.example | 5 ++-- .../channel-auto-start-unlinked.test.ts | 4 +-- .../handlers/slack/events/message-entry.ts | 9 +++--- .../slack/helpers/user-mapping.test.ts | 4 +-- .../handlers/slack/helpers/user-mapping.ts | 8 ++++-- apps/docs/environment-variables.mdx | 2 +- .../settings/UserPreferencesSection.test.tsx | 22 +++++++++------ .../settings/UserPreferencesSection.tsx | 27 +++++++++--------- .../pages/PersonalSettingsPage.test.tsx | 4 +-- .../settings/pages/PersonalSettingsPage.tsx | 8 ++++-- .../pages/PersonalSettingsRoute.test.tsx | 8 +++--- .../settings/pages/PersonalSettingsRoute.tsx | 5 ++-- .../usePersonalPreferences.client.test.tsx | 2 +- apps/web/src/hooks/usePersonalPreferences.ts | 20 ++++++------- .../trpc/commands/preferences/index.test.ts | 4 +-- .../src/trpc/commands/preferences/index.ts | 21 +++++++------- .../preferences/personal-preferences.test.ts | 28 ++++++++++--------- apps/web/src/trpc/routers/_app.ts | 4 +-- apps/web/src/types/preferences.ts | 4 +-- docker-compose.production.yml | 2 +- docker-compose.self-host.yml | 2 +- packages/env/src/__tests__/index.test.ts | 14 +++++----- packages/env/src/index.ts | 4 +-- 23 files changed, 113 insertions(+), 98 deletions(-) diff --git a/.env.production.example b/.env.production.example index 934c9fcd5..0c271c189 100644 --- a/.env.production.example +++ b/.env.production.example @@ -154,8 +154,9 @@ DEFAULT_COMPUTE_PROVIDER=docker # Set to true to prevent curated integrations from being configured or used. # Existing connections remain stored and become available again once unset. # R_CURATED_INTEGRATIONS_DISABLED=true -# Set to true to let users default their Slack messages to fast mode. -# R_SLACK_FAST_MODE_SETTING_ENABLED=true +# Set to true to let users default supported communications messages to fast mode. +# Currently applies to Slack messages only. +# R_COMMUNICATIONS_FAST_MODE_SETTING_ENABLED=true # SLACK_APP_ID= # R_SLACK_SIGNING_SECRET= # R_TELEGRAM_BOT_TOKEN= diff --git a/apps/api/src/handlers/slack/events/channel-auto-start-unlinked.test.ts b/apps/api/src/handlers/slack/events/channel-auto-start-unlinked.test.ts index aecb1430f..d823fdcb0 100644 --- a/apps/api/src/handlers/slack/events/channel-auto-start-unlinked.test.ts +++ b/apps/api/src/handlers/slack/events/channel-auto-start-unlinked.test.ts @@ -22,7 +22,7 @@ vi.mock('@roomote/env', () => ({ Env: { TRPC_URL: null, R_APP_URL: 'http://localhost:3000', - R_SLACK_FAST_MODE_SETTING_ENABLED: true, + R_COMMUNICATIONS_FAST_MODE_SETTING_ENABLED: true, }, })); @@ -150,7 +150,7 @@ describe('channel auto-start unlinked author', () => { updatedAt: new Date('2026-01-01T00:00:00.000Z'), matchedUserId: 'user-1', userDeletedAt: null, - userMetadata: { slack_fast_mode_default: true }, + userMetadata: { communications_fast_mode_default: true }, }, ]); const { handleMessageOrAppMentionEvent } = diff --git a/apps/api/src/handlers/slack/events/message-entry.ts b/apps/api/src/handlers/slack/events/message-entry.ts index 4daa20016..2308f4827 100644 --- a/apps/api/src/handlers/slack/events/message-entry.ts +++ b/apps/api/src/handlers/slack/events/message-entry.ts @@ -1231,9 +1231,9 @@ async function maybeHandleChannelAutoStart(params: { channelAutoStartEvent.text, ), deploymentSettingEnabled: - Env.R_SLACK_FAST_MODE_SETTING_ENABLED === true, + Env.R_COMMUNICATIONS_FAST_MODE_SETTING_ENABLED === true, userDefaultEnabled: - userMapping.slackFastModeDefault && + userMapping.communicationsFastModeDefault && !isRemovedEvalCommandInvocation(channelAutoStartEvent.text), }) : null; @@ -1701,9 +1701,10 @@ async function handleSlackEntryEvent(params: { const fastAgentEntryMode = resolveFastAgentEntryMode({ explicitInvocation: isFastCommandInvocation(event.text), - deploymentSettingEnabled: Env.R_SLACK_FAST_MODE_SETTING_ENABLED === true, + deploymentSettingEnabled: + Env.R_COMMUNICATIONS_FAST_MODE_SETTING_ENABLED === true, userDefaultEnabled: - userMapping.slackFastModeDefault && + userMapping.communicationsFastModeDefault && !isRemovedEvalCommandInvocation(event.text), }); diff --git a/apps/api/src/handlers/slack/helpers/user-mapping.test.ts b/apps/api/src/handlers/slack/helpers/user-mapping.test.ts index af6021f79..f339fbb5b 100644 --- a/apps/api/src/handlers/slack/helpers/user-mapping.test.ts +++ b/apps/api/src/handlers/slack/helpers/user-mapping.test.ts @@ -52,7 +52,7 @@ describe('lookupSlackUserMapping', () => { updatedAt, matchedUserId: 'user-1', userDeletedAt: null, - userMetadata: { slack_fast_mode_default: true }, + userMetadata: { communications_fast_mode_default: true }, }, ]); @@ -68,7 +68,7 @@ describe('lookupSlackUserMapping', () => { userId: 'user-1', createdAt, updatedAt, - slackFastModeDefault: true, + communicationsFastModeDefault: true, }, hasInactiveMapping: false, }); diff --git a/apps/api/src/handlers/slack/helpers/user-mapping.ts b/apps/api/src/handlers/slack/helpers/user-mapping.ts index 2ed0045f1..5537804bc 100644 --- a/apps/api/src/handlers/slack/helpers/user-mapping.ts +++ b/apps/api/src/handlers/slack/helpers/user-mapping.ts @@ -8,7 +8,9 @@ import { } from '@roomote/db/server'; type SlackUserMappingLookup = { - activeMapping: (SlackUserMapping & { slackFastModeDefault: boolean }) | null; + activeMapping: + | (SlackUserMapping & { communicationsFastModeDefault: boolean }) + | null; hasInactiveMapping: boolean; }; @@ -60,12 +62,12 @@ export async function lookupSlackUserMapping(params: { userId: row.userId, createdAt: row.createdAt, updatedAt: row.updatedAt, - slackFastModeDefault: + communicationsFastModeDefault: typeof row.userMetadata === 'object' && row.userMetadata !== null && !Array.isArray(row.userMetadata) && (row.userMetadata as Record) - .slack_fast_mode_default === true, + .communications_fast_mode_default === true, }, hasInactiveMapping: false, }; diff --git a/apps/docs/environment-variables.mdx b/apps/docs/environment-variables.mdx index e5a888b9b..c5c461950 100644 --- a/apps/docs/environment-variables.mdx +++ b/apps/docs/environment-variables.mdx @@ -127,7 +127,7 @@ as per-task auth tokens or workspace paths. | `ROOMOTE_FORCE_TELEMETRY` | Development only | Force-enables telemetry in development or preview environments when a Ping endpoint is explicitly configured. | | `R_CLOUD_ENABLED` | Roomote Cloud only | Deployment-managed switch for Roomote Cloud behavior, including required anonymous analytics and Cloud support integrations. Do not set this for self-hosted deployments. | | `R_CURATED_INTEGRATIONS_DISABLED` | Optional | Operator policy for the curated **Settings > Integrations** catalog, which is enabled by default. Set to `true` and restart Roomote to prevent those integrations from being configured or used. Existing connections remain stored while disabled and become available again once the value is unset. Communications, source-control, inference, sandbox providers, and environment-defined MCP servers are unaffected. | -| `R_SLACK_FAST_MODE_SETTING_ENABLED` | Optional | Set to `true` and restart Roomote to expose a personal setting that defaults a user's Slack messages to fast mode without requiring `!fast`. The setting is hidden and unavailable when this flag is unset. | +| `R_COMMUNICATIONS_FAST_MODE_SETTING_ENABLED` | Optional | Set to `true` and restart Roomote to expose a personal setting that defaults a user's supported communications messages to fast mode. The setting currently applies to Slack messages, where it removes the need for `!fast`; it is hidden and unavailable when this flag is unset. | | `R_GITHUB_APP_SLUG` | GitHub setup | GitHub App slug used by server-rendered setup and GitHub integration flows. | | `SETUP_TOKEN` | Required (non-local) | One-time bootstrap token that admits the first admin at `/setup`. Required on every non-local deployment — tokenless bootstrap is allowed only when `NODE_ENV` is not `production` and `R_APP_ENV` is `development`, so anything running with `NODE_ENV=production` needs it. Without it, first-admin bootstrap stays closed so nobody can claim the founding-admin slot by reaching the URL first. Optional only in local development. | | `DASHBOARD_PASSWORD` | Production | Local fallback/admin password value used by the deployment. Generate a strong secret. | diff --git a/apps/web/src/components/settings/UserPreferencesSection.test.tsx b/apps/web/src/components/settings/UserPreferencesSection.test.tsx index cebbeedc4..66cf1c648 100644 --- a/apps/web/src/components/settings/UserPreferencesSection.test.tsx +++ b/apps/web/src/components/settings/UserPreferencesSection.test.tsx @@ -28,7 +28,7 @@ const { setEnabled: vi.fn(), }, personalPreferencesState: { - preferences: { slackFastModeDefault: false }, + preferences: { communicationsFastModeDefault: false }, isLoading: false, isUpdating: false, setPreferences: vi.fn(), @@ -136,7 +136,7 @@ describe('UserPreferencesSection', () => { narrationModeState.enabled = false; narrationModeState.isLoading = false; narrationModeState.isUpdating = false; - personalPreferencesState.preferences.slackFastModeDefault = false; + personalPreferencesState.preferences.communicationsFastModeDefault = false; personalPreferencesState.isLoading = false; personalPreferencesState.isUpdating = false; }); @@ -221,26 +221,30 @@ describe('UserPreferencesSection', () => { ); }); - it('hides the Slack fast mode default when it is unavailable', () => { + it('hides the communications fast mode default when it is unavailable', () => { render(); expect( - screen.queryByLabelText('Toggle Slack fast mode default'), + screen.queryByLabelText('Toggle communications fast mode default'), ).not.toBeInTheDocument(); }); - it('updates the Slack fast mode default when it is available', () => { - personalPreferencesState.preferences.slackFastModeDefault = true; + it('updates the communications fast mode default when it is available', () => { + personalPreferencesState.preferences.communicationsFastModeDefault = true; - render(); + render( + , + ); - const toggle = screen.getByLabelText('Toggle Slack fast mode default'); + const toggle = screen.getByLabelText( + 'Toggle communications fast mode default', + ); expect(toggle).toBeChecked(); fireEvent.click(toggle); expect(personalPreferencesState.setPreferences).toHaveBeenCalledWith({ - slackFastModeDefault: false, + communicationsFastModeDefault: false, }); }); }); diff --git a/apps/web/src/components/settings/UserPreferencesSection.tsx b/apps/web/src/components/settings/UserPreferencesSection.tsx index 927b4089d..59afd3417 100644 --- a/apps/web/src/components/settings/UserPreferencesSection.tsx +++ b/apps/web/src/components/settings/UserPreferencesSection.tsx @@ -29,9 +29,9 @@ const COLOR_THEME_OPTIONS: ReadonlyArray<{ ]; export function UserPreferencesSection({ - slackFastModeDefaultAvailable = false, + communicationsFastModeDefaultAvailable = false, }: { - slackFastModeDefaultAvailable?: boolean; + communicationsFastModeDefaultAvailable?: boolean; }) { const { colorTheme, @@ -53,12 +53,12 @@ export function UserPreferencesSection({ } = useNarrationMode(); const { preferences, - isLoading: isSlackFastModeDefaultLoading, - isUpdating: isSlackFastModeDefaultUpdating, + isLoading: isCommunicationsFastModeDefaultLoading, + isUpdating: isCommunicationsFastModeDefaultUpdating, setPreferences, } = usePersonalPreferences({ - enabled: slackFastModeDefaultAvailable, - errorMessage: 'Failed to update the Slack fast mode default.', + enabled: communicationsFastModeDefaultAvailable, + errorMessage: 'Failed to update the communications fast mode default.', }); const isThemeDisabled = isThemeLoading || isThemeUpdating; @@ -128,24 +128,25 @@ export function UserPreferencesSection({ - {slackFastModeDefaultAvailable ? ( + {communicationsFastModeDefaultAvailable ? (
- setPreferences({ slackFastModeDefault: enabled }) + setPreferences({ communicationsFastModeDefault: enabled }) } />

- Default Slack messages to fast mode + Default messages to fast mode

- Use fast mode for your Slack messages without adding !fast. + Use fast mode by default for supported communications messages.

diff --git a/apps/web/src/components/settings/pages/PersonalSettingsPage.test.tsx b/apps/web/src/components/settings/pages/PersonalSettingsPage.test.tsx index bfdf2b04f..a04ea9d8a 100644 --- a/apps/web/src/components/settings/pages/PersonalSettingsPage.test.tsx +++ b/apps/web/src/components/settings/pages/PersonalSettingsPage.test.tsx @@ -53,7 +53,7 @@ describe('PersonalSettingsPage', () => { , ); @@ -71,7 +71,7 @@ describe('PersonalSettingsPage', () => { , ); diff --git a/apps/web/src/components/settings/pages/PersonalSettingsPage.tsx b/apps/web/src/components/settings/pages/PersonalSettingsPage.tsx index d3f25e00e..5df7083b5 100644 --- a/apps/web/src/components/settings/pages/PersonalSettingsPage.tsx +++ b/apps/web/src/components/settings/pages/PersonalSettingsPage.tsx @@ -13,12 +13,12 @@ export function PersonalSettingsPage({ profile, canChangePassword, canSetPassword, - slackFastModeDefaultAvailable, + communicationsFastModeDefaultAvailable, }: { profile: UserProfileSectionProfile; canChangePassword: boolean; canSetPassword: boolean; - slackFastModeDefaultAvailable: boolean; + communicationsFastModeDefaultAvailable: boolean; }) { return ( @@ -30,7 +30,9 @@ export function PersonalSettingsPage({ ) : null} diff --git a/apps/web/src/components/settings/pages/PersonalSettingsRoute.test.tsx b/apps/web/src/components/settings/pages/PersonalSettingsRoute.test.tsx index 6ae61ac89..48898e8a1 100644 --- a/apps/web/src/components/settings/pages/PersonalSettingsRoute.test.tsx +++ b/apps/web/src/components/settings/pages/PersonalSettingsRoute.test.tsx @@ -4,12 +4,12 @@ let accountCapabilities: | { canChangePassword: boolean; canSetPassword: boolean; - slackFastModeDefaultAvailable: boolean; + communicationsFastModeDefaultAvailable: boolean; } | undefined = { canChangePassword: true, canSetPassword: false, - slackFastModeDefaultAvailable: true, + communicationsFastModeDefaultAvailable: true, }; const { personalSettingsPageMock } = vi.hoisted(() => ({ @@ -49,7 +49,7 @@ describe('PersonalSettingsRoute', () => { accountCapabilities = { canChangePassword: true, canSetPassword: false, - slackFastModeDefaultAvailable: true, + communicationsFastModeDefaultAvailable: true, }; personalSettingsPageMock.mockClear(); }); @@ -61,7 +61,7 @@ describe('PersonalSettingsRoute', () => { { canChangePassword: true, canSetPassword: false, - slackFastModeDefaultAvailable: true, + communicationsFastModeDefaultAvailable: true, profile: { email: 'ada@example.com', imageUrl: 'https://example.com/ada.png', diff --git a/apps/web/src/components/settings/pages/PersonalSettingsRoute.tsx b/apps/web/src/components/settings/pages/PersonalSettingsRoute.tsx index f89cfcf41..a161f223c 100644 --- a/apps/web/src/components/settings/pages/PersonalSettingsRoute.tsx +++ b/apps/web/src/components/settings/pages/PersonalSettingsRoute.tsx @@ -18,8 +18,9 @@ export function PersonalSettingsRoute() { { colorTheme: 'system', mindReaderMode: false, narrationMode: false, - slackFastModeDefault: false, + communicationsFastModeDefault: false, }); }); diff --git a/apps/web/src/hooks/usePersonalPreferences.ts b/apps/web/src/hooks/usePersonalPreferences.ts index c85ad891f..b559d46cf 100644 --- a/apps/web/src/hooks/usePersonalPreferences.ts +++ b/apps/web/src/hooks/usePersonalPreferences.ts @@ -51,10 +51,10 @@ function mergeResultForUpdatedFields( updates.narrationMode === undefined ? mergedPreferences.narrationMode : result.narrationMode, - slackFastModeDefault: - updates.slackFastModeDefault === undefined - ? mergedPreferences.slackFastModeDefault - : result.slackFastModeDefault, + communicationsFastModeDefault: + updates.communicationsFastModeDefault === undefined + ? mergedPreferences.communicationsFastModeDefault + : result.communicationsFastModeDefault, }; } @@ -85,12 +85,12 @@ function rollbackUpdatedFields( mergedPreferences.narrationMode === optimisticPreferences.narrationMode ? previousPreferences.narrationMode : mergedPreferences.narrationMode, - slackFastModeDefault: - updates.slackFastModeDefault !== undefined && - mergedPreferences.slackFastModeDefault === - optimisticPreferences.slackFastModeDefault - ? previousPreferences.slackFastModeDefault - : mergedPreferences.slackFastModeDefault, + communicationsFastModeDefault: + updates.communicationsFastModeDefault !== undefined && + mergedPreferences.communicationsFastModeDefault === + optimisticPreferences.communicationsFastModeDefault + ? previousPreferences.communicationsFastModeDefault + : mergedPreferences.communicationsFastModeDefault, }; } diff --git a/apps/web/src/trpc/commands/preferences/index.test.ts b/apps/web/src/trpc/commands/preferences/index.test.ts index c36610270..dbeff15a5 100644 --- a/apps/web/src/trpc/commands/preferences/index.test.ts +++ b/apps/web/src/trpc/commands/preferences/index.test.ts @@ -41,7 +41,7 @@ describe('personal account capabilities', () => { await expect(getPersonalAccountCapabilitiesCommand(auth)).resolves.toEqual({ canChangePassword: false, canSetPassword: true, - slackFastModeDefaultAvailable: false, + communicationsFastModeDefaultAvailable: false, }); }); @@ -51,7 +51,7 @@ describe('personal account capabilities', () => { await expect(getPersonalAccountCapabilitiesCommand(auth)).resolves.toEqual({ canChangePassword: true, canSetPassword: false, - slackFastModeDefaultAvailable: false, + communicationsFastModeDefaultAvailable: false, }); }); diff --git a/apps/web/src/trpc/commands/preferences/index.ts b/apps/web/src/trpc/commands/preferences/index.ts index 5e1825941..9b7fd12d9 100644 --- a/apps/web/src/trpc/commands/preferences/index.ts +++ b/apps/web/src/trpc/commands/preferences/index.ts @@ -37,9 +37,9 @@ function normalizePersonalPreferences( typeof metadata.narration_mode === 'boolean' ? metadata.narration_mode : DEFAULT_PERSONAL_PREFERENCES.narrationMode, - slackFastModeDefault: - Env.R_SLACK_FAST_MODE_SETTING_ENABLED === true && - metadata.slack_fast_mode_default === true, + communicationsFastModeDefault: + Env.R_COMMUNICATIONS_FAST_MODE_SETTING_ENABLED === true && + metadata.communications_fast_mode_default === true, }; } @@ -66,8 +66,8 @@ export async function getPersonalAccountCapabilitiesCommand( return { canChangePassword: hasCredentialAccount, canSetPassword: !hasCredentialAccount, - slackFastModeDefaultAvailable: - Env.R_SLACK_FAST_MODE_SETTING_ENABLED === true, + communicationsFastModeDefaultAvailable: + Env.R_COMMUNICATIONS_FAST_MODE_SETTING_ENABLED === true, }; } @@ -120,11 +120,11 @@ export async function updatePersonalPreferencesCommand( const nextMetadataRecord: UserMetadataRecord = {}; if ( - input.slackFastModeDefault !== undefined && - Env.R_SLACK_FAST_MODE_SETTING_ENABLED !== true + input.communicationsFastModeDefault !== undefined && + Env.R_COMMUNICATIONS_FAST_MODE_SETTING_ENABLED !== true ) { throw new Error( - 'The Slack fast mode default setting is not enabled for this deployment.', + 'The communications fast mode default setting is not enabled for this deployment.', ); } @@ -140,8 +140,9 @@ export async function updatePersonalPreferencesCommand( nextMetadataRecord.narration_mode = input.narrationMode; } - if (input.slackFastModeDefault !== undefined) { - nextMetadataRecord.slack_fast_mode_default = input.slackFastModeDefault; + if (input.communicationsFastModeDefault !== undefined) { + nextMetadataRecord.communications_fast_mode_default = + input.communicationsFastModeDefault; } if (Object.keys(nextMetadataRecord).length === 0) { diff --git a/apps/web/src/trpc/commands/preferences/personal-preferences.test.ts b/apps/web/src/trpc/commands/preferences/personal-preferences.test.ts index 1cdbcf6ef..fe6c9b24d 100644 --- a/apps/web/src/trpc/commands/preferences/personal-preferences.test.ts +++ b/apps/web/src/trpc/commands/preferences/personal-preferences.test.ts @@ -3,7 +3,7 @@ import { db, eq, userFactory, users } from '@roomote/db/server'; import type { UserAuthSuccess } from '@/types'; const { mockEnv } = vi.hoisted(() => ({ - mockEnv: { R_SLACK_FAST_MODE_SETTING_ENABLED: false }, + mockEnv: { R_COMMUNICATIONS_FAST_MODE_SETTING_ENABLED: false }, })); vi.mock('@/lib/server/env', () => ({ Env: mockEnv })); @@ -19,7 +19,7 @@ function buildAuth(userId: string) { describe('personal preferences', () => { beforeEach(() => { - mockEnv.R_SLACK_FAST_MODE_SETTING_ENABLED = false; + mockEnv.R_COMMUNICATIONS_FAST_MODE_SETTING_ENABLED = false; }); it('defaults mind reader mode to disabled', async () => { @@ -71,39 +71,41 @@ describe('personal preferences', () => { ); }); - it('rejects Slack fast mode updates when the deployment setting is disabled', async () => { + it('rejects communications fast mode updates when the deployment setting is disabled', async () => { const user = await userFactory.create(); await expect( updatePersonalPreferencesCommand(buildAuth(user.id), { - slackFastModeDefault: true, + communicationsFastModeDefault: true, }), ).rejects.toThrow( - 'The Slack fast mode default setting is not enabled for this deployment.', + 'The communications fast mode default setting is not enabled for this deployment.', ); }); - it('does not expose a stored Slack fast mode default when the deployment setting is disabled', async () => { + it('does not expose a stored communications fast mode default when the deployment setting is disabled', async () => { const user = await userFactory.create({ - metadata: { slack_fast_mode_default: true }, + metadata: { communications_fast_mode_default: true }, }); await expect( getPersonalPreferencesCommand(buildAuth(user.id)), ).resolves.toEqual( - expect.objectContaining({ slackFastModeDefault: false }), + expect.objectContaining({ communicationsFastModeDefault: false }), ); }); - it('persists the Slack fast mode default when the deployment setting is enabled', async () => { - mockEnv.R_SLACK_FAST_MODE_SETTING_ENABLED = true; + it('persists the communications fast mode default when the deployment setting is enabled', async () => { + mockEnv.R_COMMUNICATIONS_FAST_MODE_SETTING_ENABLED = true; const user = await userFactory.create(); await expect( updatePersonalPreferencesCommand(buildAuth(user.id), { - slackFastModeDefault: true, + communicationsFastModeDefault: true, }), - ).resolves.toEqual(expect.objectContaining({ slackFastModeDefault: true })); + ).resolves.toEqual( + expect.objectContaining({ communicationsFastModeDefault: true }), + ); const storedUser = await db.query.users.findFirst({ where: eq(users.id, user.id), @@ -111,7 +113,7 @@ describe('personal preferences', () => { }); expect(storedUser?.metadata).toEqual( - expect.objectContaining({ slack_fast_mode_default: true }), + expect.objectContaining({ communications_fast_mode_default: true }), ); }); }); diff --git a/apps/web/src/trpc/routers/_app.ts b/apps/web/src/trpc/routers/_app.ts index eda088036..ecd12d954 100644 --- a/apps/web/src/trpc/routers/_app.ts +++ b/apps/web/src/trpc/routers/_app.ts @@ -1432,14 +1432,14 @@ export const appRouter = createRouter({ colorTheme: z.enum(PERSONAL_COLOR_THEMES).optional(), mindReaderMode: z.boolean().optional(), narrationMode: z.boolean().optional(), - slackFastModeDefault: z.boolean().optional(), + communicationsFastModeDefault: z.boolean().optional(), }) .refine( (input) => input.colorTheme !== undefined || input.mindReaderMode !== undefined || input.narrationMode !== undefined || - input.slackFastModeDefault !== undefined, + input.communicationsFastModeDefault !== undefined, { message: 'Expected at least one personal preference to update.', }, diff --git a/apps/web/src/types/preferences.ts b/apps/web/src/types/preferences.ts index e0d1db788..60dd3a4f0 100644 --- a/apps/web/src/types/preferences.ts +++ b/apps/web/src/types/preferences.ts @@ -16,7 +16,7 @@ export interface PersonalPreferences { colorTheme: PersonalColorTheme; mindReaderMode: boolean; narrationMode: boolean; - slackFastModeDefault: boolean; + communicationsFastModeDefault: boolean; } export type PersonalPreferencesUpdate = Partial; @@ -25,5 +25,5 @@ export const DEFAULT_PERSONAL_PREFERENCES: PersonalPreferences = { colorTheme: 'system', mindReaderMode: false, narrationMode: false, - slackFastModeDefault: false, + communicationsFastModeDefault: false, }; diff --git a/docker-compose.production.yml b/docker-compose.production.yml index 8aacfe159..1e4d678e0 100644 --- a/docker-compose.production.yml +++ b/docker-compose.production.yml @@ -23,7 +23,7 @@ x-roomote-production-env: &roomote-production-env R_PUBLIC_URL: https://${ROOMOTE_APP_DOMAIN:?ROOMOTE_APP_DOMAIN is required} R_INSTANCE_ID: ${R_INSTANCE_ID:-} R_CURATED_INTEGRATIONS_DISABLED: ${R_CURATED_INTEGRATIONS_DISABLED:-false} - R_SLACK_FAST_MODE_SETTING_ENABLED: ${R_SLACK_FAST_MODE_SETTING_ENABLED:-false} + R_COMMUNICATIONS_FAST_MODE_SETTING_ENABLED: ${R_COMMUNICATIONS_FAST_MODE_SETTING_ENABLED:-false} TRPC_URL: http://api:3001 PREVIEW_PROXY_BASE_URL: https://${ROOMOTE_PREVIEW_DOMAIN:?ROOMOTE_PREVIEW_DOMAIN is required} NEXT_PUBLIC_PREVIEW_PROXY_BASE_URL: https://${ROOMOTE_PREVIEW_DOMAIN:?ROOMOTE_PREVIEW_DOMAIN is required} diff --git a/docker-compose.self-host.yml b/docker-compose.self-host.yml index b507f98fe..5325eb7eb 100644 --- a/docker-compose.self-host.yml +++ b/docker-compose.self-host.yml @@ -22,7 +22,7 @@ x-roomote-env: &roomote-env R_APP_URL: ${R_PUBLIC_URL:-http://localhost:13000} R_INSTANCE_ID: ${R_INSTANCE_ID:-} R_CURATED_INTEGRATIONS_DISABLED: ${R_CURATED_INTEGRATIONS_DISABLED:-false} - R_SLACK_FAST_MODE_SETTING_ENABLED: ${R_SLACK_FAST_MODE_SETTING_ENABLED:-false} + R_COMMUNICATIONS_FAST_MODE_SETTING_ENABLED: ${R_COMMUNICATIONS_FAST_MODE_SETTING_ENABLED:-false} # The Brain: supplying this key gives the deployment shared memory that # agents consult. It powers the brain service's embeddings and is the # single activation signal — there is no Settings UI for it. diff --git a/packages/env/src/__tests__/index.test.ts b/packages/env/src/__tests__/index.test.ts index a72d0097a..b1bacc8b0 100644 --- a/packages/env/src/__tests__/index.test.ts +++ b/packages/env/src/__tests__/index.test.ts @@ -226,19 +226,19 @@ describe('Env', () => { expect(areCuratedIntegrationsDisabled('0')).toBe(false); }); - it('keeps the Slack fast mode setting opt-in', () => { + it('keeps the communications fast mode setting opt-in', () => { const runtimeEnv = { ...process.env }; delete runtimeEnv.SKIP_ENV_VALIDATION; - delete runtimeEnv.R_SLACK_FAST_MODE_SETTING_ENABLED; + delete runtimeEnv.R_COMMUNICATIONS_FAST_MODE_SETTING_ENABLED; - expect(createRoomoteEnv(runtimeEnv).R_SLACK_FAST_MODE_SETTING_ENABLED).toBe( - false, - ); + expect( + createRoomoteEnv(runtimeEnv).R_COMMUNICATIONS_FAST_MODE_SETTING_ENABLED, + ).toBe(false); expect( createRoomoteEnv({ ...runtimeEnv, - R_SLACK_FAST_MODE_SETTING_ENABLED: 'true', - }).R_SLACK_FAST_MODE_SETTING_ENABLED, + R_COMMUNICATIONS_FAST_MODE_SETTING_ENABLED: 'true', + }).R_COMMUNICATIONS_FAST_MODE_SETTING_ENABLED, ).toBe(true); }); diff --git a/packages/env/src/index.ts b/packages/env/src/index.ts index 3bf0e2db4..febb8520b 100644 --- a/packages/env/src/index.ts +++ b/packages/env/src/index.ts @@ -124,8 +124,8 @@ const serverSchema = { // Roomote Cloud-only analytics and support integrations. These values are // intentionally not used by self-hosted deployments. R_CLOUD_ENABLED: optInBoolean(), - // Exposes the per-user setting that defaults Slack messages to fast mode. - R_SLACK_FAST_MODE_SETTING_ENABLED: optInBoolean(), + // Exposes the per-user setting that defaults communications messages to fast mode. + R_COMMUNICATIONS_FAST_MODE_SETTING_ENABLED: optInBoolean(), // Operator policy for the curated Settings > Integrations catalog. Enabled // by default; operators opt out explicitly. Existing connections remain // stored but cannot be configured or used while disabled.