Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion apps/web/src/app/api/openrouter/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,9 +100,10 @@ export function useOpenRouterProviders() {
});
}

export function useModelSelectorList(organizationId: string | undefined) {
export function useModelSelectorList(organizationId: string | undefined, enabled = true) {
const query = useQuery({
queryKey: ['openrouter-models', organizationId],
enabled,
queryFn: async (): Promise<OpenRouterModelsResponse> => {
const response = await fetch(
organizationId ? `/api/organizations/${organizationId}/models` : '/api/openrouter/models'
Expand Down
109 changes: 109 additions & 0 deletions apps/web/src/app/api/organizations/[id]/modes/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { describe, expect, test } from '@jest/globals';
import { NextRequest, NextResponse } from 'next/server';
import { GET } from './route';
import { getAuthorizedOrgContext } from '@/lib/organizations/organization-auth';
import { getAllOrganizationModes } from '@/lib/organizations/organization-modes';

jest.mock('@/lib/organizations/organization-auth');
jest.mock('@/lib/organizations/organization-modes');

const mockedGetAuthorizedOrgContext = jest.mocked(getAuthorizedOrgContext);
const mockedGetAllOrganizationModes = jest.mocked(getAllOrganizationModes);

describe('GET /api/organizations/[id]/modes', () => {
test('returns defaultModel as part of the additive mode payload', async () => {
mockedGetAuthorizedOrgContext.mockResolvedValue({
success: true,
data: {
organization: { id: 'org-1' },
},
} as never);
mockedGetAllOrganizationModes.mockResolvedValue([
{
id: 'mode-1',
organization_id: 'org-1',
name: 'Code',
slug: 'code',
created_by: 'user-1',
created_at: '2026-01-01T00:00:00.000Z',
updated_at: '2026-01-01T00:00:00.000Z',
config: {
roleDefinition: 'You are a coding assistant',
groups: ['read'],
defaultModel: 'openai/gpt-4o',
},
},
]);

const response = await GET(new NextRequest('http://localhost:3000'), {
params: Promise.resolve({ id: 'org-1' }),
});

expect(response).toBeInstanceOf(NextResponse);
await expect(response.json()).resolves.toEqual({
modes: [
{
id: 'mode-1',
organization_id: 'org-1',
name: 'Code',
slug: 'code',
created_by: 'user-1',
created_at: '2026-01-01T00:00:00.000Z',
updated_at: '2026-01-01T00:00:00.000Z',
config: {
roleDefinition: 'You are a coding assistant',
groups: ['read'],
defaultModel: 'openai/gpt-4o',
},
},
],
});
expect(mockedGetAllOrganizationModes).toHaveBeenCalledWith('org-1');
});

test('returns a legacy mode row without defaultModel unchanged', async () => {
mockedGetAuthorizedOrgContext.mockResolvedValue({
success: true,
data: {
organization: { id: 'org-1' },
},
} as never);
mockedGetAllOrganizationModes.mockResolvedValue([
{
id: 'mode-1',
organization_id: 'org-1',
name: 'Code',
slug: 'code',
created_by: 'user-1',
created_at: '2026-01-01T00:00:00.000Z',
updated_at: '2026-01-01T00:00:00.000Z',
config: {
roleDefinition: 'You are a coding assistant',
groups: ['read'],
},
},
]);

const response = await GET(new NextRequest('http://localhost:3000'), {
params: Promise.resolve({ id: 'org-1' }),
});

await expect(response.json()).resolves.toEqual({
modes: [
{
id: 'mode-1',
organization_id: 'org-1',
name: 'Code',
slug: 'code',
created_by: 'user-1',
created_at: '2026-01-01T00:00:00.000Z',
updated_at: '2026-01-01T00:00:00.000Z',
config: {
roleDefinition: 'You are a coding assistant',
groups: ['read'],
},
},
],
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { ModeDrawer } from './ModeDrawer';
import { NewModeForm } from './NewModeForm';
import { EditModeForm } from './EditModeForm';
import { useOrganizationReadOnly } from '@/lib/organizations/use-organization-read-only';
import { useFeatureFlagEnabled } from 'posthog-js/react';

type CustomModesLayoutProps = {
organizationId: string;
Expand All @@ -43,9 +44,16 @@ type ModesListProps = {
readonly: boolean;
onDeleteClick: (mode: DisplayMode) => void;
onEditClick: (mode: DisplayMode) => void;
isDefaultModelConfigEnabled: boolean;
};

function ModesList({ modes, readonly, onDeleteClick, onEditClick }: ModesListProps) {
function ModesList({
modes,
readonly,
onDeleteClick,
onEditClick,
isDefaultModelConfigEnabled,
}: ModesListProps) {
return (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{modes.map((mode, index) => (
Expand Down Expand Up @@ -99,7 +107,15 @@ function ModesList({ modes, readonly, onDeleteClick, onEditClick }: ModesListPro
</div>
</CardHeader>
<CardContent className="flex-1">
<div className="flex h-full flex-col">
<div className="flex h-full flex-col gap-4">
{isDefaultModelConfigEnabled && mode.config.defaultModel && (
<div className="flex flex-wrap items-center gap-2">
<span className="text-muted-foreground text-xs font-medium">Default model</span>
<Badge variant="secondary" className="max-w-full font-mono text-xs">
<span className="break-all">{mode.config.defaultModel}</span>
</Badge>
</div>
)}
{mode.config?.groups && mode.config.groups.length > 0 && (
<div className="flex-1">
<h4 className="mb-2 text-sm font-medium">Available Tools</h4>
Expand Down Expand Up @@ -153,7 +169,9 @@ export function CustomModesLayout({ organizationId }: CustomModesLayoutProps) {
const [drawerMode, setDrawerMode] = useState<'create' | 'edit'>('create');
const [editingMode, setEditingMode] = useState<DisplayMode | null>(null);
const isReadOnly = useOrganizationReadOnly(organizationId);

const isDefaultModelFeatureEnabled = useFeatureFlagEnabled('org-default-model-config');
const isDevelopment = process.env.NODE_ENV === 'development';
const isDefaultModelConfigEnabled = isDevelopment || isDefaultModelFeatureEnabled === true;
const readonly = isReadOnly;

// Separate built-in modes and custom modes
Expand Down Expand Up @@ -297,6 +315,7 @@ export function CustomModesLayout({ organizationId }: CustomModesLayoutProps) {
readonly={readonly}
onDeleteClick={openDeleteDialog}
onEditClick={handleEditMode}
isDefaultModelConfigEnabled={isDefaultModelConfigEnabled}
/>
</div>

Expand All @@ -310,6 +329,7 @@ export function CustomModesLayout({ organizationId }: CustomModesLayoutProps) {
readonly={readonly}
onDeleteClick={openDeleteDialog}
onEditClick={handleEditMode}
isDefaultModelConfigEnabled={isDefaultModelConfigEnabled}
/>
</div>
)}
Expand Down Expand Up @@ -383,13 +403,16 @@ export function CustomModesLayout({ organizationId }: CustomModesLayoutProps) {
defaultModeSlug={
editingMode?.isDefault && !editingMode?.isOverridden ? editingMode.slug : undefined
}
isDefaultModelConfigEnabled={isDefaultModelConfigEnabled}
onSuccess={handleDrawerClose}
onCancel={handleDrawerClose}
/>
) : editingMode ? (
<EditModeForm
organizationId={organizationId}
modeId={editingMode.id}
defaultModeSlug={editingMode.isDefault ? editingMode.slug : undefined}
isDefaultModelConfigEnabled={isDefaultModelConfigEnabled}
onSuccess={handleDrawerClose}
onCancel={handleDrawerClose}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,26 +4,88 @@ import {
useOrganizationModeById,
useUpdateOrganizationMode,
useOrganizationModes,
useDeleteOrganizationMode,
} from '@/app/api/organizations/hooks';
import { ModeForm, type ModeFormData } from './ModeForm';
import { LoadingCard } from '@/components/LoadingCard';
import { ErrorCard } from '@/components/ErrorCard';
import { toast } from 'sonner';
import { DEFAULT_MODES } from './default-modes';

type EditModeFormProps = {
organizationId: string;
modeId: string;
defaultModeSlug?: string;
isDefaultModelConfigEnabled?: boolean;
onSuccess?: () => void;
onCancel?: () => void;
};

export function EditModeForm({ organizationId, modeId, onSuccess, onCancel }: EditModeFormProps) {
function normalizeOptionalValue(value: string | undefined): string | undefined {
return value || undefined;
}

function normalizeGroups(groups: unknown): string[] | undefined {
if (!Array.isArray(groups)) {
return undefined;
}

return groups.map(group => JSON.stringify(group)).sort();
}

function matchesBuiltInModeState(formData: ModeFormData, defaultModeSlug: string): boolean {
const defaultMode = DEFAULT_MODES.find(mode => mode.slug === defaultModeSlug);
if (!defaultMode) {
return false;
}

return (
formData.name === defaultMode.name &&
formData.slug === defaultMode.slug &&
formData.roleDefinition === defaultMode.config.roleDefinition &&
normalizeOptionalValue(formData.description) === defaultMode.config.description &&
normalizeOptionalValue(formData.whenToUse) === defaultMode.config.whenToUse &&
JSON.stringify(normalizeGroups(formData.groups)) ===
JSON.stringify(normalizeGroups(defaultMode.config.groups)) &&
normalizeOptionalValue(formData.customInstructions) === defaultMode.config.customInstructions
);
}

export function EditModeForm({
organizationId,
modeId,
defaultModeSlug,
isDefaultModelConfigEnabled = false,
onSuccess,
onCancel,
}: EditModeFormProps) {
const { data, isLoading, error } = useOrganizationModeById(organizationId, modeId);
const { data: modesData } = useOrganizationModes(organizationId);
const updateMutation = useUpdateOrganizationMode();
const deleteMutation = useDeleteOrganizationMode();

const handleSubmit = async (formData: ModeFormData) => {
try {
if (
defaultModeSlug &&
!formData.defaultModel &&
matchesBuiltInModeState(formData, defaultModeSlug)
) {
await deleteMutation.mutateAsync({
organizationId,
modeId,
});
toast.success(`Mode "${formData.name}" reverted successfully`);
onSuccess?.();
return;
}

const persistedDefaultModel = data?.mode?.config.defaultModel ?? '';
const defaultModelUpdate =
formData.defaultModel === persistedDefaultModel
? {}
: { defaultModel: formData.defaultModel || null };

await updateMutation.mutateAsync({
organizationId,
modeId,
Expand All @@ -35,6 +97,7 @@ export function EditModeForm({ organizationId, modeId, onSuccess, onCancel }: Ed
whenToUse: formData.whenToUse,
groups: formData.groups as ('read' | 'edit' | 'browser' | 'command' | 'mcp')[],
customInstructions: formData.customInstructions,
...defaultModelUpdate,
},
});
toast.success(`Mode "${formData.name}" updated successfully`);
Expand Down Expand Up @@ -63,9 +126,12 @@ export function EditModeForm({ organizationId, modeId, onSuccess, onCancel }: Ed

return (
<ModeForm
organizationId={organizationId}
mode={data.mode}
onSubmit={handleSubmit}
isSubmitting={updateMutation.isPending}
isSubmitting={updateMutation.isPending || deleteMutation.isPending}
isEditingBuiltIn={!!defaultModeSlug}
isDefaultModelConfigEnabled={isDefaultModelConfigEnabled}
existingModes={modesData?.modes || []}
onCancel={onCancel}
renderButtons={() => null}
Expand Down
Loading