mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-03 21:03:08 +00:00
feat: Enhance AutoModeService and UI for Cursor model support
- Updated AutoModeService to track model and provider for running features, improving logging and state management. - Modified AddFeatureDialog to handle model selection for both Claude and Cursor, adjusting thinking level logic accordingly. - Expanded ModelSelector to allow provider selection and dynamically display models based on the selected provider. - Introduced new model constants for Cursor models, integrating them into the existing model management structure. - Updated README and project plan to reflect the completion of task execution integration for Cursor models.
This commit is contained in:
@@ -316,6 +316,8 @@ interface RunningFeature {
|
|||||||
abortController: AbortController;
|
abortController: AbortController;
|
||||||
isAutoMode: boolean;
|
isAutoMode: boolean;
|
||||||
startTime: number;
|
startTime: number;
|
||||||
|
model?: string;
|
||||||
|
provider?: 'claude' | 'cursor';
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AutoLoopState {
|
interface AutoLoopState {
|
||||||
@@ -604,9 +606,16 @@ export class AutoModeService {
|
|||||||
typeof img === 'string' ? img : img.path
|
typeof img === 'string' ? img : img.path
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get model from feature
|
// Get model from feature and determine provider
|
||||||
const model = resolveModelString(feature.model, DEFAULT_MODELS.claude);
|
const model = resolveModelString(feature.model, DEFAULT_MODELS.claude);
|
||||||
console.log(`[AutoMode] Executing feature ${featureId} with model: ${model} in ${workDir}`);
|
const provider = ProviderFactory.getProviderNameForModel(model);
|
||||||
|
console.log(
|
||||||
|
`[AutoMode] Executing feature ${featureId} with model: ${model}, provider: ${provider} in ${workDir}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Store model and provider in running feature for tracking
|
||||||
|
tempRunningFeature.model = model;
|
||||||
|
tempRunningFeature.provider = provider;
|
||||||
|
|
||||||
// Run the agent with the feature's model and images
|
// Run the agent with the feature's model and images
|
||||||
// Context files are passed as system prompt for higher priority
|
// Context files are passed as system prompt for higher priority
|
||||||
@@ -640,6 +649,8 @@ export class AutoModeService {
|
|||||||
(Date.now() - tempRunningFeature.startTime) / 1000
|
(Date.now() - tempRunningFeature.startTime) / 1000
|
||||||
)}s${finalStatus === 'verified' ? ' - auto-verified' : ''}`,
|
)}s${finalStatus === 'verified' ? ' - auto-verified' : ''}`,
|
||||||
projectPath,
|
projectPath,
|
||||||
|
model: tempRunningFeature.model,
|
||||||
|
provider: tempRunningFeature.provider,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorInfo = classifyError(error);
|
const errorInfo = classifyError(error);
|
||||||
@@ -805,6 +816,13 @@ ${prompt}
|
|||||||
## Task
|
## Task
|
||||||
Address the follow-up instructions above. Review the previous work and make the requested changes or fixes.`;
|
Address the follow-up instructions above. Review the previous work and make the requested changes or fixes.`;
|
||||||
|
|
||||||
|
// Get model from feature and determine provider early for tracking
|
||||||
|
const model = resolveModelString(feature?.model, DEFAULT_MODELS.claude);
|
||||||
|
const provider = ProviderFactory.getProviderNameForModel(model);
|
||||||
|
console.log(
|
||||||
|
`[AutoMode] Follow-up for feature ${featureId} using model: ${model}, provider: ${provider}`
|
||||||
|
);
|
||||||
|
|
||||||
this.runningFeatures.set(featureId, {
|
this.runningFeatures.set(featureId, {
|
||||||
featureId,
|
featureId,
|
||||||
projectPath,
|
projectPath,
|
||||||
@@ -813,6 +831,8 @@ Address the follow-up instructions above. Review the previous work and make the
|
|||||||
abortController,
|
abortController,
|
||||||
isAutoMode: false,
|
isAutoMode: false,
|
||||||
startTime: Date.now(),
|
startTime: Date.now(),
|
||||||
|
model,
|
||||||
|
provider,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.emitAutoModeEvent('auto_mode_feature_start', {
|
this.emitAutoModeEvent('auto_mode_feature_start', {
|
||||||
@@ -823,13 +843,11 @@ Address the follow-up instructions above. Review the previous work and make the
|
|||||||
title: 'Follow-up',
|
title: 'Follow-up',
|
||||||
description: prompt.substring(0, 100),
|
description: prompt.substring(0, 100),
|
||||||
},
|
},
|
||||||
|
model,
|
||||||
|
provider,
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get model from feature (already loaded above)
|
|
||||||
const model = resolveModelString(feature?.model, DEFAULT_MODELS.claude);
|
|
||||||
console.log(`[AutoMode] Follow-up for feature ${featureId} using model: ${model}`);
|
|
||||||
|
|
||||||
// Update feature status to in_progress
|
// Update feature status to in_progress
|
||||||
await this.updateFeatureStatus(projectPath, featureId, 'in_progress');
|
await this.updateFeatureStatus(projectPath, featureId, 'in_progress');
|
||||||
|
|
||||||
@@ -925,6 +943,8 @@ Address the follow-up instructions above. Review the previous work and make the
|
|||||||
passes: true,
|
passes: true,
|
||||||
message: `Follow-up completed successfully${finalStatus === 'verified' ? ' - auto-verified' : ''}`,
|
message: `Follow-up completed successfully${finalStatus === 'verified' ? ' - auto-verified' : ''}`,
|
||||||
projectPath,
|
projectPath,
|
||||||
|
model,
|
||||||
|
provider,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorInfo = classifyError(error);
|
const errorInfo = classifyError(error);
|
||||||
@@ -1217,12 +1237,16 @@ Format your response as a structured markdown document.`;
|
|||||||
projectPath: string;
|
projectPath: string;
|
||||||
projectName: string;
|
projectName: string;
|
||||||
isAutoMode: boolean;
|
isAutoMode: boolean;
|
||||||
|
model?: string;
|
||||||
|
provider?: 'claude' | 'cursor';
|
||||||
}> {
|
}> {
|
||||||
return Array.from(this.runningFeatures.values()).map((rf) => ({
|
return Array.from(this.runningFeatures.values()).map((rf) => ({
|
||||||
featureId: rf.featureId,
|
featureId: rf.featureId,
|
||||||
projectPath: rf.projectPath,
|
projectPath: rf.projectPath,
|
||||||
projectName: path.basename(rf.projectPath),
|
projectName: path.basename(rf.projectPath),
|
||||||
isAutoMode: rf.isAutoMode,
|
isAutoMode: rf.isAutoMode,
|
||||||
|
model: rf.model,
|
||||||
|
provider: rf.provider,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -312,11 +312,18 @@ export function AddFeatureDialog({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleModelSelect = (model: AgentModel) => {
|
const handleModelSelect = (model: string) => {
|
||||||
|
// For Cursor models, thinking is handled by the model itself
|
||||||
|
// For Claude models, check if it supports extended thinking
|
||||||
|
const isCursorModel = model.startsWith('cursor-');
|
||||||
setNewFeature({
|
setNewFeature({
|
||||||
...newFeature,
|
...newFeature,
|
||||||
model,
|
model: model as AgentModel,
|
||||||
thinkingLevel: modelSupportsThinking(model) ? newFeature.thinkingLevel : 'none',
|
thinkingLevel: isCursorModel
|
||||||
|
? 'none'
|
||||||
|
: modelSupportsThinking(model)
|
||||||
|
? newFeature.thinkingLevel
|
||||||
|
: 'none',
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -328,7 +335,9 @@ export function AddFeatureDialog({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const newModelAllowsThinking = modelSupportsThinking(newFeature.model);
|
// Cursor models handle thinking internally, so only show thinking selector for Claude models
|
||||||
|
const isCursorModel = newFeature.model.startsWith('cursor-');
|
||||||
|
const newModelAllowsThinking = !isCursorModel && modelSupportsThinking(newFeature.model);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={handleDialogClose}>
|
<Dialog open={open} onOpenChange={handleDialogClose}>
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
import type { AgentModel, ThinkingLevel } from '@/store/app-store';
|
import type { AgentModel, ThinkingLevel } from '@/store/app-store';
|
||||||
|
import type { ModelProvider } from '@automaker/types';
|
||||||
|
import { CURSOR_MODEL_MAP } from '@automaker/types';
|
||||||
import { Brain, Zap, Scale, Cpu, Rocket, Sparkles } from 'lucide-react';
|
import { Brain, Zap, Scale, Cpu, Rocket, Sparkles } from 'lucide-react';
|
||||||
|
|
||||||
export type ModelOption = {
|
export type ModelOption = {
|
||||||
id: AgentModel;
|
id: string; // Claude models use AgentModel, Cursor models use "cursor-{id}"
|
||||||
label: string;
|
label: string;
|
||||||
description: string;
|
description: string;
|
||||||
badge?: string;
|
badge?: string;
|
||||||
provider: 'claude';
|
provider: ModelProvider;
|
||||||
|
hasThinking?: boolean;
|
||||||
|
tier?: 'free' | 'pro';
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CLAUDE_MODELS: ModelOption[] = [
|
export const CLAUDE_MODELS: ModelOption[] = [
|
||||||
@@ -33,6 +37,26 @@ export const CLAUDE_MODELS: ModelOption[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cursor models derived from CURSOR_MODEL_MAP
|
||||||
|
* ID is prefixed with "cursor-" for ProviderFactory routing
|
||||||
|
*/
|
||||||
|
export const CURSOR_MODELS: ModelOption[] = Object.entries(CURSOR_MODEL_MAP).map(
|
||||||
|
([id, config]) => ({
|
||||||
|
id: `cursor-${id}`,
|
||||||
|
label: config.label,
|
||||||
|
description: config.description,
|
||||||
|
provider: 'cursor' as ModelProvider,
|
||||||
|
hasThinking: config.hasThinking,
|
||||||
|
tier: config.tier,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All available models (Claude + Cursor)
|
||||||
|
*/
|
||||||
|
export const ALL_MODELS: ModelOption[] = [...CLAUDE_MODELS, ...CURSOR_MODELS];
|
||||||
|
|
||||||
export const THINKING_LEVELS: ThinkingLevel[] = ['none', 'low', 'medium', 'high', 'ultrathink'];
|
export const THINKING_LEVELS: ThinkingLevel[] = ['none', 'low', 'medium', 'high', 'ultrathink'];
|
||||||
|
|
||||||
export const THINKING_LEVEL_LABELS: Record<ThinkingLevel, string> = {
|
export const THINKING_LEVEL_LABELS: Record<ThinkingLevel, string> = {
|
||||||
|
|||||||
@@ -1,54 +1,178 @@
|
|||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Brain } from 'lucide-react';
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Brain, Bot, Terminal } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { AgentModel } from '@/store/app-store';
|
import type { AgentModel } from '@/store/app-store';
|
||||||
import { CLAUDE_MODELS, ModelOption } from './model-constants';
|
import type { ModelProvider } from '@automaker/types';
|
||||||
|
import { CLAUDE_MODELS, CURSOR_MODELS, ModelOption } from './model-constants';
|
||||||
|
|
||||||
interface ModelSelectorProps {
|
interface ModelSelectorProps {
|
||||||
selectedModel: AgentModel;
|
selectedModel: string; // Can be AgentModel or "cursor-{id}"
|
||||||
onModelSelect: (model: AgentModel) => void;
|
onModelSelect: (model: string) => void;
|
||||||
testIdPrefix?: string;
|
testIdPrefix?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the provider from a model string
|
||||||
|
*/
|
||||||
|
function getProviderFromModelString(model: string): ModelProvider {
|
||||||
|
if (model.startsWith('cursor-')) {
|
||||||
|
return 'cursor';
|
||||||
|
}
|
||||||
|
return 'claude';
|
||||||
|
}
|
||||||
|
|
||||||
export function ModelSelector({
|
export function ModelSelector({
|
||||||
selectedModel,
|
selectedModel,
|
||||||
onModelSelect,
|
onModelSelect,
|
||||||
testIdPrefix = 'model-select',
|
testIdPrefix = 'model-select',
|
||||||
}: ModelSelectorProps) {
|
}: ModelSelectorProps) {
|
||||||
|
const selectedProvider = getProviderFromModelString(selectedModel);
|
||||||
|
|
||||||
|
const handleProviderChange = (provider: ModelProvider) => {
|
||||||
|
if (provider === 'cursor' && selectedProvider !== 'cursor') {
|
||||||
|
// Switch to Cursor's default model
|
||||||
|
onModelSelect('cursor-auto');
|
||||||
|
} else if (provider === 'claude' && selectedProvider !== 'claude') {
|
||||||
|
// Switch to Claude's default model
|
||||||
|
onModelSelect('sonnet');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
{/* Provider Selection */}
|
||||||
<Label className="flex items-center gap-2">
|
<div className="space-y-2">
|
||||||
<Brain className="w-4 h-4 text-primary" />
|
<Label>AI Provider</Label>
|
||||||
Claude (SDK)
|
<div className="flex gap-2">
|
||||||
</Label>
|
<button
|
||||||
<span className="text-[11px] px-2 py-0.5 rounded-full border border-primary/40 text-primary">
|
type="button"
|
||||||
Native
|
onClick={() => handleProviderChange('claude')}
|
||||||
</span>
|
className={cn(
|
||||||
</div>
|
'flex-1 px-3 py-2 rounded-md border text-sm font-medium transition-colors flex items-center justify-center gap-2',
|
||||||
<div className="flex gap-2 flex-wrap">
|
selectedProvider === 'claude'
|
||||||
{CLAUDE_MODELS.map((option) => {
|
? 'bg-primary text-primary-foreground border-primary'
|
||||||
const isSelected = selectedModel === option.id;
|
: 'bg-background hover:bg-accent border-border'
|
||||||
const shortName = option.label.replace('Claude ', '');
|
)}
|
||||||
return (
|
data-testid={`${testIdPrefix}-provider-claude`}
|
||||||
<button
|
>
|
||||||
key={option.id}
|
<Bot className="w-4 h-4" />
|
||||||
type="button"
|
Claude
|
||||||
onClick={() => onModelSelect(option.id)}
|
</button>
|
||||||
title={option.description}
|
<button
|
||||||
className={cn(
|
type="button"
|
||||||
'flex-1 min-w-[80px] px-3 py-2 rounded-md border text-sm font-medium transition-colors',
|
onClick={() => handleProviderChange('cursor')}
|
||||||
isSelected
|
className={cn(
|
||||||
? 'bg-primary text-primary-foreground border-primary'
|
'flex-1 px-3 py-2 rounded-md border text-sm font-medium transition-colors flex items-center justify-center gap-2',
|
||||||
: 'bg-background hover:bg-accent border-input'
|
selectedProvider === 'cursor'
|
||||||
)}
|
? 'bg-primary text-primary-foreground border-primary'
|
||||||
data-testid={`${testIdPrefix}-${option.id}`}
|
: 'bg-background hover:bg-accent border-border'
|
||||||
>
|
)}
|
||||||
{shortName}
|
data-testid={`${testIdPrefix}-provider-cursor`}
|
||||||
</button>
|
>
|
||||||
);
|
<Terminal className="w-4 h-4" />
|
||||||
})}
|
Cursor CLI
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Claude Models */}
|
||||||
|
{selectedProvider === 'claude' && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="flex items-center gap-2">
|
||||||
|
<Brain className="w-4 h-4 text-primary" />
|
||||||
|
Claude Model
|
||||||
|
</Label>
|
||||||
|
<span className="text-[11px] px-2 py-0.5 rounded-full border border-primary/40 text-primary">
|
||||||
|
Native SDK
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
{CLAUDE_MODELS.map((option) => {
|
||||||
|
const isSelected = selectedModel === option.id;
|
||||||
|
const shortName = option.label.replace('Claude ', '');
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onModelSelect(option.id)}
|
||||||
|
title={option.description}
|
||||||
|
className={cn(
|
||||||
|
'flex-1 min-w-[80px] px-3 py-2 rounded-md border text-sm font-medium transition-colors',
|
||||||
|
isSelected
|
||||||
|
? 'bg-primary text-primary-foreground border-primary'
|
||||||
|
: 'bg-background hover:bg-accent border-input'
|
||||||
|
)}
|
||||||
|
data-testid={`${testIdPrefix}-${option.id}`}
|
||||||
|
>
|
||||||
|
{shortName}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Cursor Models */}
|
||||||
|
{selectedProvider === 'cursor' && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="flex items-center gap-2">
|
||||||
|
<Terminal className="w-4 h-4 text-primary" />
|
||||||
|
Cursor Model
|
||||||
|
</Label>
|
||||||
|
<span className="text-[11px] px-2 py-0.5 rounded-full border border-amber-500/40 text-amber-600 dark:text-amber-400">
|
||||||
|
CLI
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{CURSOR_MODELS.map((option) => {
|
||||||
|
const isSelected = selectedModel === option.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onModelSelect(option.id)}
|
||||||
|
title={option.description}
|
||||||
|
className={cn(
|
||||||
|
'w-full px-3 py-2 rounded-md border text-sm font-medium transition-colors flex items-center justify-between',
|
||||||
|
isSelected
|
||||||
|
? 'bg-primary text-primary-foreground border-primary'
|
||||||
|
: 'bg-background hover:bg-accent border-border'
|
||||||
|
)}
|
||||||
|
data-testid={`${testIdPrefix}-${option.id}`}
|
||||||
|
>
|
||||||
|
<span>{option.label}</span>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{option.hasThinking && (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
'text-xs',
|
||||||
|
isSelected
|
||||||
|
? 'border-primary-foreground/50 text-primary-foreground'
|
||||||
|
: 'border-amber-500/50 text-amber-600 dark:text-amber-400'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Thinking
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{option.tier && (
|
||||||
|
<Badge
|
||||||
|
variant={option.tier === 'free' ? 'default' : 'secondary'}
|
||||||
|
className={cn('text-xs', isSelected && 'bg-primary-foreground/20')}
|
||||||
|
>
|
||||||
|
{option.tier}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
| 6 | [UI Setup Wizard](phases/phase-6-setup-wizard.md) | `completed` | ✅ |
|
| 6 | [UI Setup Wizard](phases/phase-6-setup-wizard.md) | `completed` | ✅ |
|
||||||
| 7 | [Settings View Provider Tabs](phases/phase-7-settings.md) | `completed` | ✅ |
|
| 7 | [Settings View Provider Tabs](phases/phase-7-settings.md) | `completed` | ✅ |
|
||||||
| 8 | [AI Profiles Integration](phases/phase-8-profiles.md) | `completed` | ✅ |
|
| 8 | [AI Profiles Integration](phases/phase-8-profiles.md) | `completed` | ✅ |
|
||||||
| 9 | [Task Execution Integration](phases/phase-9-execution.md) | `pending` | - |
|
| 9 | [Task Execution Integration](phases/phase-9-execution.md) | `completed` | ✅ |
|
||||||
| 10 | [Testing & Validation](phases/phase-10-testing.md) | `pending` | - |
|
| 10 | [Testing & Validation](phases/phase-10-testing.md) | `pending` | - |
|
||||||
|
|
||||||
**Status Legend:** `pending` | `in_progress` | `completed` | `blocked`
|
**Status Legend:** `pending` | `in_progress` | `completed` | `blocked`
|
||||||
@@ -393,3 +393,4 @@ Cursor models use their own `CURSOR_MODEL_MAP` in `@automaker/types`.
|
|||||||
| 2025-12-27 | 1 | Added tasks 1.5-1.7: ModelOption, DEFAULT_MODELS, reuse InstallationStatus |
|
| 2025-12-27 | 1 | Added tasks 1.5-1.7: ModelOption, DEFAULT_MODELS, reuse InstallationStatus |
|
||||||
| 2025-12-27 | 2 | Refactored to use `spawnJSONLProcess` and `isAbortError` from @automaker packages |
|
| 2025-12-27 | 2 | Refactored to use `spawnJSONLProcess` and `isAbortError` from @automaker packages |
|
||||||
| 2025-12-27 | - | Added design decisions 4-5: @automaker packages usage, model-resolver note |
|
| 2025-12-27 | - | Added design decisions 4-5: @automaker packages usage, model-resolver note |
|
||||||
|
| 2025-12-28 | 9 | Completed: ModelSelector with Cursor models, provider tracking in execution events |
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Phase 9: Task Execution Integration
|
# Phase 9: Task Execution Integration
|
||||||
|
|
||||||
**Status:** `pending`
|
**Status:** `completed`
|
||||||
**Dependencies:** Phase 3 (Factory), Phase 8 (Profiles)
|
**Dependencies:** Phase 3 (Factory), Phase 8 (Profiles)
|
||||||
**Estimated Effort:** Medium (service updates)
|
**Estimated Effort:** Medium (service updates)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user