feat(profiles): de-emphasize AI suggestions and fix multi-tenant support

UI/UX Improvements for AI-skeptical users:
- Only show 'Ideas' button if AI is enabled AND configured
- Renamed 'Suggest Profile' to 'Ideas' with lightbulb icon
- Moved 'New Profile' button to primary position
- Changed AI button styling from prominent purple to subtle gray
- Updated modal title to 'Profile Ideas' with neutral language

Multi-tenant bug fix:
- ProfileSuggestionHandler now uses MultiTenantPersistence
- Properly resolves tenant-specific persistence from request context
- Fixes potential nil pointer panic in multi-tenant deployments
- Existing profiles are now correctly loaded per-tenant for AI context

Tests updated to use MultiTenantPersistence with org context injection.
This commit is contained in:
rcourtman
2026-02-04 11:39:50 +00:00
parent a73352a396
commit fd108faa7d
6 changed files with 82 additions and 57 deletions

View File

@@ -3,6 +3,7 @@ import { useWebSocket } from '@/App';
import { Card } from '@/components/shared/Card';
import SettingsPanel from '@/components/shared/SettingsPanel';
import { AgentProfilesAPI, type AgentProfile, type AgentProfileAssignment, type ProfileSuggestion } from '@/api/agentProfiles';
import { AIAPI } from '@/api/ai';
import { LicenseAPI } from '@/api/license';
import { notificationStore } from '@/stores/notifications';
import { logger } from '@/utils/logger';
@@ -15,7 +16,7 @@ import Trash2 from 'lucide-solid/icons/trash-2';
import Crown from 'lucide-solid/icons/crown';
import Users from 'lucide-solid/icons/users';
import Settings from 'lucide-solid/icons/settings';
import Sparkles from 'lucide-solid/icons/sparkles';
import Lightbulb from 'lucide-solid/icons/lightbulb';
export const AgentProfilesPanel: Component = () => {
@@ -25,6 +26,9 @@ export const AgentProfilesPanel: Component = () => {
const [hasFeature, setHasFeature] = createSignal(false);
const [checkingLicense, setCheckingLicense] = createSignal(true);
// AI state - only show AI features if enabled
const [aiAvailable, setAiAvailable] = createSignal(false);
// Data state
const [profiles, setProfiles] = createSignal<AgentProfile[]>([]);
const [assignments, setAssignments] = createSignal<AgentProfileAssignment[]>([]);
@@ -100,7 +104,7 @@ export const AgentProfilesPanel: Component = () => {
}
};
// Check license on mount
// Check license and AI availability on mount
onMount(async () => {
try {
const features = await LicenseAPI.getFeatures();
@@ -112,6 +116,15 @@ export const AgentProfilesPanel: Component = () => {
setCheckingLicense(false);
}
// Check if AI is available (enabled and configured) - silently fail if not
try {
const aiSettings = await AIAPI.getSettings();
setAiAvailable(aiSettings.enabled && aiSettings.configured);
} catch {
// AI not available - that's fine, just hide the Ideas button
setAiAvailable(false);
}
if (hasFeature()) {
await loadData();
} else {
@@ -284,15 +297,6 @@ export const AgentProfilesPanel: Component = () => {
bodyClass="space-y-4"
action={
<div class="flex items-center gap-2">
<button
type="button"
onClick={handleSuggest}
class="inline-flex items-center gap-2 rounded-lg bg-purple-600 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-purple-700 sm:px-4 sm:py-2 sm:text-sm"
>
<Sparkles class="w-4 h-4" />
<span class="hidden sm:inline">Suggest Profile</span>
<span class="sm:hidden">Suggest</span>
</button>
<button
type="button"
onClick={handleCreate}
@@ -302,6 +306,18 @@ export const AgentProfilesPanel: Component = () => {
<span class="hidden sm:inline">New Profile</span>
<span class="sm:hidden">New</span>
</button>
{/* Only show AI Ideas button if AI is enabled and configured */}
<Show when={aiAvailable()}>
<button
type="button"
onClick={handleSuggest}
title="Get AI-powered profile suggestions"
class="inline-flex items-center gap-1.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-1.5 text-xs font-medium text-gray-700 dark:text-gray-300 transition-colors hover:bg-gray-50 dark:hover:bg-gray-700 sm:px-3 sm:py-2 sm:text-sm"
>
<Lightbulb class="w-3.5 h-3.5" />
<span class="hidden sm:inline">Ideas</span>
</button>
</Show>
</div>
}
>

View File

@@ -4,7 +4,7 @@ import { notificationStore } from '@/stores/notifications';
import { logger } from '@/utils/logger';
import { formatRelativeTime } from '@/utils/format';
import { KNOWN_SETTINGS_BY_KEY } from './agentProfileSettings';
import Sparkles from 'lucide-solid/icons/sparkles';
import Lightbulb from 'lucide-solid/icons/lightbulb';
import AlertCircle from 'lucide-solid/icons/alert-circle';
import Check from 'lucide-solid/icons/check';
import Loader2 from 'lucide-solid/icons/loader-2';
@@ -242,15 +242,15 @@ export const SuggestProfileModal: Component<SuggestProfileModalProps> = (props)
{/* Header */}
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700 shrink-0">
<div class="flex items-center gap-3">
<div class="flex items-center justify-center w-8 h-8 rounded-lg bg-purple-100 dark:bg-purple-900/30">
<Sparkles class="w-4 h-4 text-purple-600 dark:text-purple-400" />
<div class="flex items-center justify-center w-8 h-8 rounded-lg bg-amber-100 dark:bg-amber-900/30">
<Lightbulb class="w-4 h-4 text-amber-600 dark:text-amber-400" />
</div>
<div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">
Pulse Assistant Profile Suggestion
Profile Ideas
</h3>
<p class="text-xs text-gray-500 dark:text-gray-400">
Describe what you need, and Pulse Assistant will draft a profile
Describe what you need, and we'll help draft a profile
</p>
</div>
</div>
@@ -595,10 +595,10 @@ export const SuggestProfileModal: Component<SuggestProfileModalProps> = (props)
type="button"
onClick={handleSubmit}
disabled={loading() || !prompt().trim()}
class="inline-flex items-center gap-2 rounded-lg bg-purple-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-purple-700 disabled:cursor-not-allowed disabled:opacity-60"
class="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-60"
>
<Sparkles class="w-4 h-4" />
{loading() ? 'Generating...' : 'Suggest Profile'}
<Lightbulb class="w-4 h-4" />
{loading() ? 'Generating...' : 'Get Ideas'}
</button>
}
>
@@ -606,11 +606,11 @@ export const SuggestProfileModal: Component<SuggestProfileModalProps> = (props)
type="button"
onClick={handleSubmit}
disabled={loading() || !prompt().trim()}
class="inline-flex items-center gap-2 rounded-lg bg-purple-100 px-4 py-2 text-sm font-medium text-purple-700 transition-colors hover:bg-purple-200 dark:bg-purple-900/40 dark:text-purple-200 dark:hover:bg-purple-900/60 disabled:cursor-not-allowed disabled:opacity-60"
class="inline-flex items-center gap-2 rounded-lg bg-gray-100 px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700 disabled:cursor-not-allowed disabled:opacity-60"
title="Regenerate using the current prompt"
>
<Sparkles class="w-4 h-4" />
{loading() ? 'Generating...' : 'Regenerate draft'}
<Lightbulb class="w-4 h-4" />
{loading() ? 'Generating...' : 'Try Again'}
</button>
<button
type="button"

View File

@@ -106,7 +106,7 @@ describe('SuggestProfileModal', () => {
target: { value: 'Production profile with minimal logging' },
},
);
fireEvent.click(screen.getByRole('button', { name: /suggest profile/i }));
fireEvent.click(screen.getByRole('button', { name: /get ideas/i }));
expect(await screen.findByText('Production Servers')).toBeInTheDocument();
expect(screen.getByText('Settings Preview')).toBeInTheDocument();
@@ -146,12 +146,12 @@ describe('SuggestProfileModal', () => {
const promptInput = screen.getByPlaceholderText('Describe the agents and use case for this profile...');
fireEvent.input(promptInput, { target: { value: 'Production profile' } });
fireEvent.click(screen.getByRole('button', { name: /suggest profile/i }));
fireEvent.click(screen.getByRole('button', { name: /get ideas/i }));
expect(await screen.findByText('Production Profile')).toBeInTheDocument();
fireEvent.input(promptInput, { target: { value: 'Development profile' } });
fireEvent.click(screen.getByRole('button', { name: /regenerate/i }));
fireEvent.click(screen.getByRole('button', { name: /try again/i }));
expect(await screen.findByText('Development Profile')).toBeInTheDocument();
expect(screen.getByText('Recent drafts')).toBeInTheDocument();

View File

@@ -39,15 +39,7 @@ func (h *ConfigProfileHandler) getPersistence(ctx context.Context) (*config.Conf
// SetAIHandler sets the AI handler for profile suggestions
func (h *ConfigProfileHandler) SetAIHandler(aiHandler *AIHandler) {
// We pass nil for persistence here because the suggestion handler will need
// to use the context-aware persistence, which requires deeper refactoring of ProfileSuggestionHandler.
// For now, we'll let ProfileSuggestionHandler resolve persistence from AIHandler if possible,
// or we update ProfileSuggestionHandler to be multi-tenant aware as well.
// Actually, ProfileSuggestionHandler needs persistence. Let's look at that separately.
// For this step, we'll temporarilly break this or pass nil and fix it in the next step.
// A better approach: ProfileSuggestionHandler should take MultiTenantPersistence too.
// Let's assume we update ProfileSuggestionHandler next.
h.suggestionHandler = NewProfileSuggestionHandler(nil, aiHandler)
h.suggestionHandler = NewProfileSuggestionHandler(h.mtPersistence, aiHandler)
}
// ServeHTTP implements the http.Handler interface

View File

@@ -16,15 +16,15 @@ import (
// ProfileSuggestionHandler handles AI-assisted profile suggestions
type ProfileSuggestionHandler struct {
persistence *config.ConfigPersistence
aiHandler *AIHandler
mtPersistence *config.MultiTenantPersistence
aiHandler *AIHandler
}
// NewProfileSuggestionHandler creates a new suggestion handler
func NewProfileSuggestionHandler(persistence *config.ConfigPersistence, aiHandler *AIHandler) *ProfileSuggestionHandler {
func NewProfileSuggestionHandler(mtp *config.MultiTenantPersistence, aiHandler *AIHandler) *ProfileSuggestionHandler {
return &ProfileSuggestionHandler{
persistence: persistence,
aiHandler: aiHandler,
mtPersistence: mtp,
aiHandler: aiHandler,
}
}
@@ -71,14 +71,20 @@ func (h *ProfileSuggestionHandler) HandleSuggestProfile(w http.ResponseWriter, r
// Build context for the AI
contextParts := []string{}
// Add existing profiles for reference
profiles, err := h.persistence.LoadAgentProfiles()
if err == nil && len(profiles) > 0 {
profileNames := make([]string, len(profiles))
for i, p := range profiles {
profileNames[i] = p.Name
// Add existing profiles for reference (if persistence is available)
if h.mtPersistence != nil {
orgID := GetOrgID(r.Context())
persistence, err := h.mtPersistence.GetPersistence(orgID)
if err == nil {
profiles, err := persistence.LoadAgentProfiles()
if err == nil && len(profiles) > 0 {
profileNames := make([]string, len(profiles))
for i, p := range profiles {
profileNames[i] = p.Name
}
contextParts = append(contextParts, fmt.Sprintf("Existing profiles: %s", strings.Join(profileNames, ", ")))
}
}
contextParts = append(contextParts, fmt.Sprintf("Existing profiles: %s", strings.Join(profileNames, ", ")))
}
// Build config schema documentation from the actual definitions

View File

@@ -14,9 +14,15 @@ import (
"github.com/stretchr/testify/mock"
)
// withOrgContext adds the default org ID to the request context
func withOrgContext(req *http.Request) *http.Request {
ctx := context.WithValue(req.Context(), OrgIDContextKey, "default")
return req.WithContext(ctx)
}
func TestProfileSuggestionHandler_MethodNotAllowed(t *testing.T) {
handler := NewProfileSuggestionHandler(config.NewConfigPersistence(t.TempDir()), &AIHandler{})
req := httptest.NewRequest(http.MethodGet, "/api/admin/profiles/suggestions", nil)
handler := NewProfileSuggestionHandler(config.NewMultiTenantPersistence(t.TempDir()), &AIHandler{})
req := withOrgContext(httptest.NewRequest(http.MethodGet, "/api/admin/profiles/suggestions", nil))
rr := httptest.NewRecorder()
handler.HandleSuggestProfile(rr, req)
@@ -31,8 +37,8 @@ func TestProfileSuggestionHandler_ServiceUnavailable(t *testing.T) {
mockSvc.On("IsRunning").Return(false)
aiHandler := &AIHandler{legacyService: mockSvc}
handler := NewProfileSuggestionHandler(config.NewConfigPersistence(t.TempDir()), aiHandler)
req := httptest.NewRequest(http.MethodPost, "/api/admin/profiles/suggestions", bytes.NewReader([]byte(`{"prompt":"test"}`)))
handler := NewProfileSuggestionHandler(config.NewMultiTenantPersistence(t.TempDir()), aiHandler)
req := withOrgContext(httptest.NewRequest(http.MethodPost, "/api/admin/profiles/suggestions", bytes.NewReader([]byte(`{"prompt":"test"}`))))
rr := httptest.NewRecorder()
handler.HandleSuggestProfile(rr, req)
@@ -46,16 +52,16 @@ func TestProfileSuggestionHandler_InvalidRequest(t *testing.T) {
mockSvc := new(MockAIService)
mockSvc.On("IsRunning").Return(true)
aiHandler := &AIHandler{legacyService: mockSvc}
handler := NewProfileSuggestionHandler(config.NewConfigPersistence(t.TempDir()), aiHandler)
handler := NewProfileSuggestionHandler(config.NewMultiTenantPersistence(t.TempDir()), aiHandler)
req := httptest.NewRequest(http.MethodPost, "/api/admin/profiles/suggestions", bytes.NewReader([]byte("{bad")))
req := withOrgContext(httptest.NewRequest(http.MethodPost, "/api/admin/profiles/suggestions", bytes.NewReader([]byte("{bad"))))
rr := httptest.NewRecorder()
handler.HandleSuggestProfile(rr, req)
if rr.Code != http.StatusBadRequest {
t.Fatalf("expected status %d, got %d", http.StatusBadRequest, rr.Code)
}
req = httptest.NewRequest(http.MethodPost, "/api/admin/profiles/suggestions", bytes.NewReader([]byte(`{"prompt":" "}`)))
req = withOrgContext(httptest.NewRequest(http.MethodPost, "/api/admin/profiles/suggestions", bytes.NewReader([]byte(`{"prompt":" "}`))))
rr = httptest.NewRecorder()
handler.HandleSuggestProfile(rr, req)
if rr.Code != http.StatusBadRequest {
@@ -64,7 +70,12 @@ func TestProfileSuggestionHandler_InvalidRequest(t *testing.T) {
}
func TestProfileSuggestionHandler_SuccessAndParseFailure(t *testing.T) {
persistence := config.NewConfigPersistence(t.TempDir())
mtPersistence := config.NewMultiTenantPersistence(t.TempDir())
// Get the default tenant's persistence to save test data
persistence, err := mtPersistence.GetPersistence("default")
if err != nil {
t.Fatalf("get persistence: %v", err)
}
if err := persistence.SaveAgentProfiles([]models.AgentProfile{{Name: "Existing"}}); err != nil {
t.Fatalf("save profiles: %v", err)
}
@@ -76,9 +87,9 @@ func TestProfileSuggestionHandler_SuccessAndParseFailure(t *testing.T) {
}, nil).Once()
aiHandler := &AIHandler{legacyService: mockSvc}
handler := NewProfileSuggestionHandler(persistence, aiHandler)
handler := NewProfileSuggestionHandler(mtPersistence, aiHandler)
req := httptest.NewRequest(http.MethodPost, "/api/admin/profiles/suggestions", bytes.NewReader([]byte(`{"prompt":"build a profile"}`)))
req := withOrgContext(httptest.NewRequest(http.MethodPost, "/api/admin/profiles/suggestions", bytes.NewReader([]byte(`{"prompt":"build a profile"}`))))
rr := httptest.NewRecorder()
handler.HandleSuggestProfile(rr, req)
@@ -101,7 +112,7 @@ func TestProfileSuggestionHandler_SuccessAndParseFailure(t *testing.T) {
"content": "not json",
}, nil).Once()
req = httptest.NewRequest(http.MethodPost, "/api/admin/profiles/suggestions", bytes.NewReader([]byte(`{"prompt":"bad response"}`)))
req = withOrgContext(httptest.NewRequest(http.MethodPost, "/api/admin/profiles/suggestions", bytes.NewReader([]byte(`{"prompt":"bad response"}`))))
rr = httptest.NewRecorder()
handler.HandleSuggestProfile(rr, req)
if rr.Code != http.StatusInternalServerError {