diff --git a/apps/docs/environments/definition.mdx b/apps/docs/environments/definition.mdx index a8b40594c4..d7d1c950f5 100644 --- a/apps/docs/environments/definition.mdx +++ b/apps/docs/environments/definition.mdx @@ -378,14 +378,16 @@ repository has a known trap, state it plainly. ## Routing rules -Admins can configure natural-language routing rules from **Settings → -Environments**. Each rule maps a description, such as “Messages sent in the -hospital-bugs Slack channel,” to an environment or the broad **All repositories** -workspace. - -Rules are guidance rather than exact string matchers. An environment explicitly -named in a request takes precedence, and a specific rule takes precedence over a -catch-all default. +Admins can configure free-text routing guidance from **Settings → Environments**. +The same guidance can describe environment choices, model preferences, or both. +For example: “Use the Web environment for frontend work” or “Use the Astra model +on high reasoning for complicated work or Luna if the task is straightforward.” +Model-only guidance does not need to name an environment. + +Routing guidance is supplemental rather than an exact string matcher. An +environment or model explicitly named in a request takes precedence. Guidance +cannot select disabled models, unavailable environments, or override Roomote's +permissions and system policies. ## MCP servers diff --git a/apps/web/src/components/settings/environments/EnvironmentRoutingOverview.client.test.tsx b/apps/web/src/components/settings/environments/EnvironmentRoutingOverview.client.test.tsx index 1392a1350b..607e7e9e0e 100644 --- a/apps/web/src/components/settings/environments/EnvironmentRoutingOverview.client.test.tsx +++ b/apps/web/src/components/settings/environments/EnvironmentRoutingOverview.client.test.tsx @@ -1,24 +1,14 @@ -import { fireEvent, render, screen } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { vi } from 'vitest'; import { EnvironmentRoutingOverview } from './EnvironmentRoutingOverview'; const mutateAsync = vi.fn(); -const environments = [{ id: 'env-1', name: 'Hospital app' }]; const routingSettings = { - rules: [ - { - description: 'Messages from hospital-bugs belong here.', - target: 'env-1', - }, - ], + guidance: 'Use Hospital app for messages from hospital-bugs.', }; vi.mock('@/hooks/environments', () => ({ - useEnvironments: () => ({ - isPending: false, - data: environments, - }), useWorkspaceRoutingSettings: () => ({ isPending: false, data: routingSettings, @@ -30,37 +20,49 @@ vi.mock('@/hooks/environments', () => ({ })); describe('EnvironmentRoutingOverview', () => { - it('does not persist a deletion when edit starts', async () => { + beforeEach(() => { + mutateAsync.mockReset(); + }); + + it('loads and saves free-text environment and model guidance', async () => { + mutateAsync.mockResolvedValue({ + guidance: 'Use Hospital app for frontend work. Prefer GPT-5.6.', + }); render(); - fireEvent.click( - screen.getByRole('button', { - name: 'Edit Messages from hospital-bugs belong here.', - }), + const textarea = screen.getByLabelText('Routing guidance'); + expect(textarea).toHaveValue( + 'Use Hospital app for messages from hospital-bugs.', ); - - expect(mutateAsync).not.toHaveBeenCalled(); - expect(screen.getByLabelText('Rule description')).toHaveValue( - 'Messages from hospital-bugs belong here.', + expect(textarea).toHaveAttribute( + 'placeholder', + 'Use the Web environment for frontend work.\n\nUse the Astra model on high reasoning for complicated work or Luna if the task is straightforward.', ); - expect(screen.getByRole('button', { name: 'Save Rule' })).toBeEnabled(); + fireEvent.change(textarea, { + target: { value: 'Use Hospital app for frontend work. Prefer GPT-5.6.' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + + await waitFor(() => { + expect(mutateAsync).toHaveBeenCalledWith({ + guidance: 'Use Hospital app for frontend work. Prefer GPT-5.6.', + }); + expect(screen.queryByRole('button', { name: 'Save' })).toBeNull(); + }); }); - it('cancels an edit without persisting changes', () => { + it('clears saved guidance', async () => { + mutateAsync.mockResolvedValue({ guidance: '' }); render(); - fireEvent.click( - screen.getByRole('button', { - name: 'Edit Messages from hospital-bugs belong here.', - }), - ); - fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); + fireEvent.change(screen.getByLabelText('Routing guidance'), { + target: { value: '' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); - expect(mutateAsync).not.toHaveBeenCalled(); - expect(screen.getByLabelText('Rule description')).toHaveValue(''); - expect(screen.getByRole('button', { name: 'Add Rule' })).toBeDisabled(); - expect( - screen.queryByRole('button', { name: 'Cancel' }), - ).not.toBeInTheDocument(); + await waitFor(() => { + expect(mutateAsync).toHaveBeenCalledWith({ guidance: '' }); + expect(screen.queryByRole('button', { name: 'Save' })).toBeNull(); + }); }); }); diff --git a/apps/web/src/components/settings/environments/EnvironmentRoutingOverview.tsx b/apps/web/src/components/settings/environments/EnvironmentRoutingOverview.tsx index a66d58d0c8..eada530c67 100644 --- a/apps/web/src/components/settings/environments/EnvironmentRoutingOverview.tsx +++ b/apps/web/src/components/settings/environments/EnvironmentRoutingOverview.tsx @@ -2,220 +2,93 @@ import { useEffect, useState } from 'react'; import { toast } from 'sonner'; -import { - ALL_REPOSITORIES, - type WorkspaceRoutingSettings, -} from '@roomote/types'; -import { GitBranch, Pencil, Trash2 } from '@/components/system'; +import { MAX_WORKSPACE_ROUTING_GUIDANCE_LENGTH } from '@roomote/types'; import { Button, - Input, - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, + GitBranch, + Label, Skeleton, + Textarea, } from '@/components/system'; import { Section } from '@/components/settings'; import { - useEnvironments, useUpdateWorkspaceRoutingSettings, useWorkspaceRoutingSettings, } from '@/hooks/environments'; -const EMPTY_RULE = { description: '', target: '' }; - export function EnvironmentRoutingOverview() { - const environments = useEnvironments(); const settings = useWorkspaceRoutingSettings(); const updateSettings = useUpdateWorkspaceRoutingSettings(); - const [rules, setRules] = useState([]); - const [draftRule, setDraftRule] = useState(EMPTY_RULE); - const [editingIndex, setEditingIndex] = useState(null); + const [guidance, setGuidance] = useState(''); + const [savedGuidance, setSavedGuidance] = useState(''); useEffect(() => { - setRules(settings.data?.rules ?? []); + const nextGuidance = settings.data?.guidance ?? ''; + setGuidance(nextGuidance); + setSavedGuidance(nextGuidance); }, [settings.data]); - if (environments.isPending || settings.isPending) { + if (settings.isPending) { return (
- +
); } - const saveRules = async (nextRules: WorkspaceRoutingSettings['rules']) => { - await updateSettings.mutateAsync({ rules: nextRules }); - setRules(nextRules); - toast.success('Routing rules updated'); - }; + const isDirty = guidance !== savedGuidance; + const footer = isDirty ? ( + <> + + + + ) : undefined; return ( -
-

- Routing rules help Roomote agents pick the right environment or broad - workspace. Describe when Roomote should choose a specific environment; - the router prioritizes matching rules unless the user explicitly - overrides them. -

- -
-
- - setDraftRule((current) => ({ - ...current, - description: event.target.value.slice(0, 500), - })) - } - /> - - setDraftRule((current) => ({ ...current, target })) - } - /> -
- {editingIndex !== null && ( - - )} - -
-
- -
- Description - Target - -
- - {rules.length === 0 ? ( -

- No routing rules configured. -

- ) : ( - rules.map((rule, index) => ( -
- {rule.description} - - {rule.target === ALL_REPOSITORIES - ? 'All repositories' - : environments.data?.find( - (environment) => environment.id === rule.target, - )?.name || rule.target} - -
- - -
-
- )) - )} +
+
+

+ Describe how Roomote should choose environments and models. Explicit + choices in a request always take priority. +

+ +