mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-04 09:13:08 +00:00
feat(phase-model-selector): implement grouped model selection and enhanced UI
- Added support for grouped models in the PhaseModelSelector, allowing users to select from multiple variants within a single group. - Introduced a new popover UI for displaying grouped model variants, improving user interaction and selection clarity. - Implemented logic to filter and display enabled cursor models, including standalone and grouped options. - Enhanced state management for expanded groups and variant selection, ensuring a smoother user experience. This update significantly improves the model selection process, making it more intuitive and organized.
This commit is contained in:
@@ -1,10 +1,17 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
import type { ModelAlias, CursorModelId } from '@automaker/types';
|
import type { ModelAlias, CursorModelId, GroupedModel } from '@automaker/types';
|
||||||
import { stripProviderPrefix } from '@automaker/types';
|
import {
|
||||||
|
stripProviderPrefix,
|
||||||
|
CURSOR_MODEL_GROUPS,
|
||||||
|
STANDALONE_CURSOR_MODELS,
|
||||||
|
getModelGroup,
|
||||||
|
isGroupSelected,
|
||||||
|
getSelectedVariant,
|
||||||
|
} from '@automaker/types';
|
||||||
import { CLAUDE_MODELS, CURSOR_MODELS } from '@/components/views/board-view/shared/model-constants';
|
import { CLAUDE_MODELS, CURSOR_MODELS } from '@/components/views/board-view/shared/model-constants';
|
||||||
import { Check, ChevronsUpDown, Star, Brain, Sparkles } from 'lucide-react';
|
import { Check, ChevronsUpDown, Star, Brain, Sparkles, ChevronRight } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Command,
|
Command,
|
||||||
@@ -31,8 +38,34 @@ export function PhaseModelSelector({
|
|||||||
onChange,
|
onChange,
|
||||||
}: PhaseModelSelectorProps) {
|
}: PhaseModelSelectorProps) {
|
||||||
const [open, setOpen] = React.useState(false);
|
const [open, setOpen] = React.useState(false);
|
||||||
|
const [expandedGroup, setExpandedGroup] = React.useState<string | null>(null);
|
||||||
|
const commandListRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
const expandedTriggerRef = React.useRef<HTMLDivElement>(null);
|
||||||
const { enabledCursorModels, favoriteModels, toggleFavoriteModel } = useAppStore();
|
const { enabledCursorModels, favoriteModels, toggleFavoriteModel } = useAppStore();
|
||||||
|
|
||||||
|
// Close expanded group when trigger scrolls out of view
|
||||||
|
React.useEffect(() => {
|
||||||
|
const triggerElement = expandedTriggerRef.current;
|
||||||
|
const listElement = commandListRef.current;
|
||||||
|
if (!triggerElement || !listElement || !expandedGroup) return;
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
const entry = entries[0];
|
||||||
|
if (!entry.isIntersecting) {
|
||||||
|
setExpandedGroup(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
root: listElement,
|
||||||
|
threshold: 0.1, // Close when less than 10% visible
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
observer.observe(triggerElement);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [expandedGroup]);
|
||||||
|
|
||||||
// Filter Cursor models to only show enabled ones
|
// Filter Cursor models to only show enabled ones
|
||||||
const availableCursorModels = CURSOR_MODELS.filter((model) => {
|
const availableCursorModels = CURSOR_MODELS.filter((model) => {
|
||||||
const cursorId = stripProviderPrefix(model.id) as CursorModelId;
|
const cursorId = stripProviderPrefix(model.id) as CursorModelId;
|
||||||
@@ -47,9 +80,55 @@ export function PhaseModelSelector({
|
|||||||
const cursorModel = availableCursorModels.find((m) => stripProviderPrefix(m.id) === value);
|
const cursorModel = availableCursorModels.find((m) => stripProviderPrefix(m.id) === value);
|
||||||
if (cursorModel) return { ...cursorModel, icon: Sparkles };
|
if (cursorModel) return { ...cursorModel, icon: Sparkles };
|
||||||
|
|
||||||
|
// Check if value is part of a grouped model
|
||||||
|
const group = getModelGroup(value as CursorModelId);
|
||||||
|
if (group) {
|
||||||
|
const variant = getSelectedVariant(group, value as CursorModelId);
|
||||||
|
return {
|
||||||
|
id: value,
|
||||||
|
label: `${group.label} (${variant?.label || 'Unknown'})`,
|
||||||
|
description: group.description,
|
||||||
|
provider: 'cursor' as const,
|
||||||
|
icon: Sparkles,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}, [value, availableCursorModels]);
|
}, [value, availableCursorModels]);
|
||||||
|
|
||||||
|
// Compute grouped vs standalone Cursor models
|
||||||
|
const { groupedModels, standaloneCursorModels } = React.useMemo(() => {
|
||||||
|
const grouped: GroupedModel[] = [];
|
||||||
|
const standalone: typeof CURSOR_MODELS = [];
|
||||||
|
const seenGroups = new Set<string>();
|
||||||
|
|
||||||
|
availableCursorModels.forEach((model) => {
|
||||||
|
const cursorId = stripProviderPrefix(model.id) as CursorModelId;
|
||||||
|
|
||||||
|
// Check if this model is standalone
|
||||||
|
if (STANDALONE_CURSOR_MODELS.includes(cursorId)) {
|
||||||
|
standalone.push(model);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this model belongs to a group
|
||||||
|
const group = getModelGroup(cursorId);
|
||||||
|
if (group && !seenGroups.has(group.baseId)) {
|
||||||
|
// Filter variants to only include enabled models
|
||||||
|
const enabledVariants = group.variants.filter((v) => enabledCursorModels.includes(v.id));
|
||||||
|
if (enabledVariants.length > 0) {
|
||||||
|
grouped.push({
|
||||||
|
...group,
|
||||||
|
variants: enabledVariants,
|
||||||
|
});
|
||||||
|
seenGroups.add(group.baseId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { groupedModels: grouped, standaloneCursorModels: standalone };
|
||||||
|
}, [availableCursorModels, enabledCursorModels]);
|
||||||
|
|
||||||
// Group models
|
// Group models
|
||||||
const { favorites, claude, cursor } = React.useMemo(() => {
|
const { favorites, claude, cursor } = React.useMemo(() => {
|
||||||
const favs: typeof CLAUDE_MODELS = [];
|
const favs: typeof CLAUDE_MODELS = [];
|
||||||
@@ -133,6 +212,120 @@ export function PhaseModelSelector({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Render a grouped model with secondary popover for variant selection
|
||||||
|
const renderGroupedModelItem = (group: GroupedModel) => {
|
||||||
|
const groupIsSelected = isGroupSelected(group, value as CursorModelId);
|
||||||
|
const selectedVariant = getSelectedVariant(group, value as CursorModelId);
|
||||||
|
const isExpanded = expandedGroup === group.baseId;
|
||||||
|
|
||||||
|
const variantTypeLabel =
|
||||||
|
group.variantType === 'compute'
|
||||||
|
? 'Compute Level'
|
||||||
|
: group.variantType === 'thinking'
|
||||||
|
? 'Reasoning Mode'
|
||||||
|
: 'Capacity Options';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CommandItem
|
||||||
|
key={group.baseId}
|
||||||
|
value={group.label}
|
||||||
|
onSelect={() => setExpandedGroup(isExpanded ? null : group.baseId)}
|
||||||
|
className="p-0 data-[selected=true]:bg-transparent"
|
||||||
|
>
|
||||||
|
<Popover
|
||||||
|
open={isExpanded}
|
||||||
|
onOpenChange={(isOpen) => {
|
||||||
|
if (!isOpen) {
|
||||||
|
setExpandedGroup(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<div
|
||||||
|
ref={isExpanded ? expandedTriggerRef : undefined}
|
||||||
|
className={cn(
|
||||||
|
'w-full group flex items-center justify-between py-2 px-2 rounded-sm cursor-pointer',
|
||||||
|
'hover:bg-accent',
|
||||||
|
isExpanded && 'bg-accent'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 overflow-hidden">
|
||||||
|
<Sparkles
|
||||||
|
className={cn(
|
||||||
|
'h-4 w-4 shrink-0',
|
||||||
|
groupIsSelected ? 'text-primary' : 'text-muted-foreground'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col truncate">
|
||||||
|
<span className={cn('truncate font-medium', groupIsSelected && 'text-primary')}>
|
||||||
|
{group.label}
|
||||||
|
</span>
|
||||||
|
<span className="truncate text-xs text-muted-foreground">
|
||||||
|
{selectedVariant ? `Selected: ${selectedVariant.label}` : group.description}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1 ml-2">
|
||||||
|
{groupIsSelected && <Check className="h-4 w-4 text-primary shrink-0" />}
|
||||||
|
<ChevronRight
|
||||||
|
className={cn(
|
||||||
|
'h-4 w-4 text-muted-foreground transition-transform',
|
||||||
|
isExpanded && 'rotate-90'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
side="right"
|
||||||
|
align="center"
|
||||||
|
avoidCollisions={false}
|
||||||
|
className="w-[220px] p-1"
|
||||||
|
sideOffset={8}
|
||||||
|
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="px-2 py-1.5 text-xs font-medium text-muted-foreground border-b border-border/50 mb-1">
|
||||||
|
{variantTypeLabel}
|
||||||
|
</div>
|
||||||
|
{group.variants.map((variant) => (
|
||||||
|
<button
|
||||||
|
key={variant.id}
|
||||||
|
onClick={() => {
|
||||||
|
onChange(variant.id);
|
||||||
|
setExpandedGroup(null);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
'w-full flex items-center justify-between px-2 py-2 rounded-sm text-sm',
|
||||||
|
'hover:bg-accent cursor-pointer transition-colors',
|
||||||
|
value === variant.id && 'bg-accent text-accent-foreground'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-start">
|
||||||
|
<span className="font-medium">{variant.label}</span>
|
||||||
|
{variant.description && (
|
||||||
|
<span className="text-xs text-muted-foreground">{variant.description}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{variant.badge && (
|
||||||
|
<span className="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
|
||||||
|
{variant.badge}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{value === variant.id && <Check className="h-3.5 w-3.5 text-primary" />}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</CommandItem>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -168,15 +361,40 @@ export function PhaseModelSelector({
|
|||||||
<PopoverContent className="w-[320px] p-0" align="end">
|
<PopoverContent className="w-[320px] p-0" align="end">
|
||||||
<Command>
|
<Command>
|
||||||
<CommandInput placeholder="Search models..." />
|
<CommandInput placeholder="Search models..." />
|
||||||
<CommandList className="max-h-[300px]">
|
<CommandList ref={commandListRef} className="max-h-[300px]">
|
||||||
<CommandEmpty>No model found.</CommandEmpty>
|
<CommandEmpty>No model found.</CommandEmpty>
|
||||||
|
|
||||||
{favorites.length > 0 && (
|
{favorites.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<CommandGroup heading="Favorites">
|
<CommandGroup heading="Favorites">
|
||||||
{favorites.map((model) =>
|
{(() => {
|
||||||
renderModelItem(model, model.provider === 'claude' ? 'claude' : 'cursor')
|
const renderedGroups = new Set<string>();
|
||||||
)}
|
return favorites.map((model) => {
|
||||||
|
// Check if this favorite is part of a grouped model
|
||||||
|
if (model.provider === 'cursor') {
|
||||||
|
const cursorId = stripProviderPrefix(model.id) as CursorModelId;
|
||||||
|
const group = getModelGroup(cursorId);
|
||||||
|
if (group) {
|
||||||
|
// Skip if we already rendered this group
|
||||||
|
if (renderedGroups.has(group.baseId)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
renderedGroups.add(group.baseId);
|
||||||
|
// Find the group in groupedModels (which has filtered variants)
|
||||||
|
const filteredGroup = groupedModels.find(
|
||||||
|
(g) => g.baseId === group.baseId
|
||||||
|
);
|
||||||
|
if (filteredGroup) {
|
||||||
|
return renderGroupedModelItem(filteredGroup);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return renderModelItem(
|
||||||
|
model,
|
||||||
|
model.provider === 'claude' ? 'claude' : 'cursor'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
})()}
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
<CommandSeparator />
|
<CommandSeparator />
|
||||||
</>
|
</>
|
||||||
@@ -188,9 +406,12 @@ export function PhaseModelSelector({
|
|||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{cursor.length > 0 && (
|
{(groupedModels.length > 0 || standaloneCursorModels.length > 0) && (
|
||||||
<CommandGroup heading="Cursor Models">
|
<CommandGroup heading="Cursor Models">
|
||||||
{cursor.map((model) => renderModelItem(model, 'cursor'))}
|
{/* Grouped models with secondary popover */}
|
||||||
|
{groupedModels.map((group) => renderGroupedModelItem(group))}
|
||||||
|
{/* Standalone models */}
|
||||||
|
{standaloneCursorModels.map((model) => renderModelItem(model, 'cursor'))}
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
)}
|
)}
|
||||||
</CommandList>
|
</CommandList>
|
||||||
|
|||||||
@@ -166,3 +166,170 @@ export function getCursorModelLabel(modelId: CursorModelId): string {
|
|||||||
export function getAllCursorModelIds(): CursorModelId[] {
|
export function getAllCursorModelIds(): CursorModelId[] {
|
||||||
return Object.keys(CURSOR_MODEL_MAP) as CursorModelId[];
|
return Object.keys(CURSOR_MODEL_MAP) as CursorModelId[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Model Grouping System
|
||||||
|
// Groups related model variants (e.g., gpt-5.2 + gpt-5.2-high) for UI display
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type of variant options available for grouped models
|
||||||
|
*/
|
||||||
|
export type VariantType = 'compute' | 'thinking' | 'capacity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single variant option within a grouped model
|
||||||
|
*/
|
||||||
|
export interface ModelVariant {
|
||||||
|
id: CursorModelId;
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
badge?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A grouped model that contains multiple variant options
|
||||||
|
*/
|
||||||
|
export interface GroupedModel {
|
||||||
|
baseId: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
variantType: VariantType;
|
||||||
|
variants: ModelVariant[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration for grouping Cursor models with variants
|
||||||
|
*/
|
||||||
|
export const CURSOR_MODEL_GROUPS: GroupedModel[] = [
|
||||||
|
// GPT-5.2 group (compute levels)
|
||||||
|
{
|
||||||
|
baseId: 'gpt-5.2-group',
|
||||||
|
label: 'GPT-5.2',
|
||||||
|
description: 'OpenAI GPT-5.2 via Cursor',
|
||||||
|
variantType: 'compute',
|
||||||
|
variants: [
|
||||||
|
{ id: 'gpt-5.2', label: 'Standard', description: 'Default compute level' },
|
||||||
|
{
|
||||||
|
id: 'gpt-5.2-high',
|
||||||
|
label: 'High',
|
||||||
|
description: 'High compute level',
|
||||||
|
badge: 'More tokens',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
// GPT-5.1 group (compute levels)
|
||||||
|
{
|
||||||
|
baseId: 'gpt-5.1-group',
|
||||||
|
label: 'GPT-5.1',
|
||||||
|
description: 'OpenAI GPT-5.1 via Cursor',
|
||||||
|
variantType: 'compute',
|
||||||
|
variants: [
|
||||||
|
{ id: 'gpt-5.1', label: 'Standard', description: 'Default compute level' },
|
||||||
|
{
|
||||||
|
id: 'gpt-5.1-high',
|
||||||
|
label: 'High',
|
||||||
|
description: 'High compute level',
|
||||||
|
badge: 'More tokens',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
// GPT-5.1 Codex group (capacity + compute matrix)
|
||||||
|
{
|
||||||
|
baseId: 'gpt-5.1-codex-group',
|
||||||
|
label: 'GPT-5.1 Codex',
|
||||||
|
description: 'OpenAI GPT-5.1 Codex for code generation',
|
||||||
|
variantType: 'capacity',
|
||||||
|
variants: [
|
||||||
|
{ id: 'gpt-5.1-codex', label: 'Standard', description: 'Default capacity' },
|
||||||
|
{ id: 'gpt-5.1-codex-high', label: 'High', description: 'High compute', badge: 'Compute' },
|
||||||
|
{ id: 'gpt-5.1-codex-max', label: 'Max', description: 'Maximum capacity', badge: 'Capacity' },
|
||||||
|
{
|
||||||
|
id: 'gpt-5.1-codex-max-high',
|
||||||
|
label: 'Max High',
|
||||||
|
description: 'Max capacity + high compute',
|
||||||
|
badge: 'Premium',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
// Sonnet 4.5 group (thinking mode)
|
||||||
|
{
|
||||||
|
baseId: 'sonnet-4.5-group',
|
||||||
|
label: 'Claude Sonnet 4.5',
|
||||||
|
description: 'Anthropic Claude Sonnet 4.5 via Cursor',
|
||||||
|
variantType: 'thinking',
|
||||||
|
variants: [
|
||||||
|
{ id: 'sonnet-4.5', label: 'Standard', description: 'Fast responses' },
|
||||||
|
{
|
||||||
|
id: 'sonnet-4.5-thinking',
|
||||||
|
label: 'Thinking',
|
||||||
|
description: 'Extended reasoning',
|
||||||
|
badge: 'Reasoning',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
// Opus 4.5 group (thinking mode)
|
||||||
|
{
|
||||||
|
baseId: 'opus-4.5-group',
|
||||||
|
label: 'Claude Opus 4.5',
|
||||||
|
description: 'Anthropic Claude Opus 4.5 via Cursor',
|
||||||
|
variantType: 'thinking',
|
||||||
|
variants: [
|
||||||
|
{ id: 'opus-4.5', label: 'Standard', description: 'Fast responses' },
|
||||||
|
{
|
||||||
|
id: 'opus-4.5-thinking',
|
||||||
|
label: 'Thinking',
|
||||||
|
description: 'Extended reasoning',
|
||||||
|
badge: 'Reasoning',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cursor models that are not part of any group (standalone)
|
||||||
|
*/
|
||||||
|
export const STANDALONE_CURSOR_MODELS: CursorModelId[] = [
|
||||||
|
'auto',
|
||||||
|
'composer-1',
|
||||||
|
'opus-4.1',
|
||||||
|
'gemini-3-pro',
|
||||||
|
'gemini-3-flash',
|
||||||
|
'grok',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the group that a model belongs to (if any)
|
||||||
|
*/
|
||||||
|
export function getModelGroup(modelId: CursorModelId): GroupedModel | undefined {
|
||||||
|
return CURSOR_MODEL_GROUPS.find((group) => group.variants.some((v) => v.id === modelId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if any variant in a group is the currently selected model
|
||||||
|
*/
|
||||||
|
export function isGroupSelected(
|
||||||
|
group: GroupedModel,
|
||||||
|
currentModelId: CursorModelId | undefined
|
||||||
|
): boolean {
|
||||||
|
if (!currentModelId) return false;
|
||||||
|
return group.variants.some((v) => v.id === currentModelId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the currently selected variant within a group
|
||||||
|
*/
|
||||||
|
export function getSelectedVariant(
|
||||||
|
group: GroupedModel,
|
||||||
|
currentModelId: CursorModelId | undefined
|
||||||
|
): ModelVariant | undefined {
|
||||||
|
if (!currentModelId) return undefined;
|
||||||
|
return group.variants.find((v) => v.id === currentModelId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a model ID belongs to a group
|
||||||
|
*/
|
||||||
|
export function isGroupedCursorModel(modelId: CursorModelId): boolean {
|
||||||
|
return CURSOR_MODEL_GROUPS.some((group) => group.variants.some((v) => v.id === modelId));
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user