From cc948b022c9eafb56d4ad182133e5f8751a43dd6 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Fri, 10 Jul 2026 11:38:23 +0100 Subject: [PATCH] Route keyless Patrol setup to Provider & Models instead of a dead-end model check A fresh install with no AI provider looped: the Patrol zero state sent users to the Patrol model check, the check told them to enable Pulse Assistant in a settings page that does not exist by that name, and the Patrol model field silently degraded to a bare text input with no hint that a provider key or Ollama server was the missing step (#1463, #847). - Preflight and readiness copy now names the real surfaces (Provider & Models, Patrol settings) and the real first step: add an API key or an Ollama server. - The Patrol zero-state CTA, header Fix setup link, and readiness banners route config-level causes (assistant_disabled, provider_not_configured) to Provider & Models; model-level causes keep the Check Patrol model action. - The Patrol model field shows a linked zero-provider notice instead of a bare text input when no provider is configured. - The preflight result box no longer renders the same failure message twice. --- .../Settings/AIModelSelectionSection.tsx | 41 ++++++++++++++----- .../patrol/PatrolIntelligenceBanners.tsx | 10 ++--- .../patrol/PatrolIntelligenceHeader.tsx | 10 ++--- .../patrol/PatrolIntelligenceWorkspace.tsx | 16 ++++---- .../PatrolIntelligenceHeader.test.ts | 6 +-- .../PatrolIntelligenceWorkspace.test.ts | 3 +- .../__tests__/patrolRuntimeActions.test.ts | 24 +++++++++++ .../src/utils/patrolRuntimeActions.ts | 21 ++++++++++ internal/ai/patrol_preflight.go | 8 ++-- internal/ai/patrol_readiness.go | 4 +- internal/api/ai_handlers.go | 4 +- 11 files changed, 107 insertions(+), 40 deletions(-) diff --git a/frontend-modern/src/components/Settings/AIModelSelectionSection.tsx b/frontend-modern/src/components/Settings/AIModelSelectionSection.tsx index 7ce7e46e1..369e78025 100644 --- a/frontend-modern/src/components/Settings/AIModelSelectionSection.tsx +++ b/frontend-modern/src/components/Settings/AIModelSelectionSection.tsx @@ -1,6 +1,8 @@ +import { A } from '@solidjs/router'; import { Component, Show } from 'solid-js'; import { AIProviderConfigurationSection } from '@/components/Settings/AIProviderConfigurationSection'; import { isModelProviderConfigured } from '@/components/Settings/aiSettingsModel'; +import { settingsTabPath } from '@/components/Settings/settingsNavigationModel'; import type { AISettingsState } from '@/components/Settings/useAISettingsState'; import { AIModelPicker } from '@/components/shared/AIModelPicker'; import { formField, labelClass, controlClass } from '@/components/shared/Form'; @@ -122,7 +124,10 @@ export const PatrolPreflightControl: Component<{ state: AISettingsState }> = (co if (r.cause === 'model_tool_support_unverified') { return 'Run Patrol once to confirm this model works correctly in practice.'; } - return r.summary || r.message || ''; + // Summary and message are often the same string on config-level + // failures; don't render the headline twice. + const fallback = r.summary || r.message || ''; + return fallback === headline() ? '' : fallback; }; const formatDuration = (ms: number) => { @@ -230,15 +235,31 @@ export const AIModelOverrideField: Component<{ 0} fallback={ - setSelectedModel(e.currentTarget.value)} - placeholder="Use shared default model" - aria-label={config().ariaLabel} - class={controlClass()} - disabled={state.saving()} - /> + + No AI provider is configured yet. Add an API key or an Ollama server on{' '} + + Provider & Models + + , then pick a model here. +

+ } + > + setSelectedModel(e.currentTarget.value)} + placeholder="Use shared default model" + aria-label={config().ariaLabel} + class={controlClass()} + disabled={state.saving()} + /> +
} > - {PATROL_PROVIDER_SETTINGS_ACTION.label} + {getPatrolSetupAction(state.patrolReadiness()?.cause).label} @@ -261,11 +261,11 @@ export function PatrolIntelligenceBanners(props: { state: PatrolIntelligenceStat diff --git a/frontend-modern/src/features/patrol/PatrolIntelligenceHeader.tsx b/frontend-modern/src/features/patrol/PatrolIntelligenceHeader.tsx index 459d4e3c5..0a0ddcbb4 100644 --- a/frontend-modern/src/features/patrol/PatrolIntelligenceHeader.tsx +++ b/frontend-modern/src/features/patrol/PatrolIntelligenceHeader.tsx @@ -21,7 +21,7 @@ import { formatRelativeTime } from '@/utils/format'; import { getPatrolPageHeaderMeta } from '@/utils/patrolPagePresentation'; import { getPatrolTriggerStatusSummary } from '@/utils/patrolRunPresentation'; import { getPatrolRuntimePresentation } from '@/utils/patrolRuntimePresentation'; -import { getPatrolProviderSettingsAction } from '@/utils/patrolRuntimeActions'; +import { getPatrolSetupAction } from '@/utils/patrolRuntimeActions'; import { getPatrolRecencyPresentation } from '@/utils/patrolSummaryPresentation'; import { PATROL_CONTROL_ANCHOR, PATROL_OPERATIONS_LOOP_ANCHOR } from '@/routing/resourceLinks'; import type { PatrolConfigurationFailureInput } from './patrolInvestigationContextModel'; @@ -74,7 +74,7 @@ export function PatrolIntelligenceHeader(props: { state: PatrolIntelligenceState manualRunBlockedReason: state.triggerPatrolDisabledReason(), }), ); - const providerSetupAction = getPatrolProviderSettingsAction(); + const providerSetupAction = () => getPatrolSetupAction(state.patrolReadiness()?.cause); const runControlBusy = createMemo( () => state.isTriggeringPatrol() || state.manualRunRequested() || state.patrolStream.isStreaming(), @@ -99,9 +99,9 @@ export function PatrolIntelligenceHeader(props: { state: PatrolIntelligenceState when={!runBlockedByProviderSetup()} fallback={ diff --git a/frontend-modern/src/features/patrol/PatrolIntelligenceWorkspace.tsx b/frontend-modern/src/features/patrol/PatrolIntelligenceWorkspace.tsx index 107830b0b..7e9421854 100644 --- a/frontend-modern/src/features/patrol/PatrolIntelligenceWorkspace.tsx +++ b/frontend-modern/src/features/patrol/PatrolIntelligenceWorkspace.tsx @@ -22,7 +22,7 @@ import { import { Button, ButtonLink } from '@/components/shared/Button'; import { MetadataBadge } from '@/components/shared/MetadataBadge'; import { StatusIndicatorBadge } from '@/components/shared/StatusIndicatorBadge'; -import { getPatrolProviderSettingsAction } from '@/utils/patrolRuntimeActions'; +import { getPatrolSetupAction, getPatrolSetupHint } from '@/utils/patrolRuntimeActions'; import { getPatrolProInvestigationHandoff, getPatrolQueueBadgeLabel, @@ -67,7 +67,8 @@ export function PatrolIntelligenceWorkspace(props: { state: PatrolIntelligenceSt const isHistoryOpen = () => state.activeTab() === 'history'; const isSetupOnly = () => !isHistoryOpen() && !state.selectedRun() && state.shouldShowPatrolSetupOnly(); - const setupAction = getPatrolProviderSettingsAction(); + const setupAction = () => getPatrolSetupAction(state.patrolReadiness()?.cause); + const setupHint = () => getPatrolSetupHint(state.patrolReadiness()?.cause); const setupFinding = () => state.findingsTabBadgeFindings().find(isPatrolRuntimeFinding); const setupReason = () => { const finding = setupFinding(); @@ -318,18 +319,17 @@ export function PatrolIntelligenceWorkspace(props: { state: PatrolIntelligenceSt

Patrol cannot run yet

{setupReason()}

-

- Open Patrol settings and run the model check. Provider connectivity can be healthy - even when the selected model cannot use Patrol tools. -

+ +

{setupHint()}

+
diff --git a/frontend-modern/src/features/patrol/__tests__/PatrolIntelligenceHeader.test.ts b/frontend-modern/src/features/patrol/__tests__/PatrolIntelligenceHeader.test.ts index dbd2f8710..c81459533 100644 --- a/frontend-modern/src/features/patrol/__tests__/PatrolIntelligenceHeader.test.ts +++ b/frontend-modern/src/features/patrol/__tests__/PatrolIntelligenceHeader.test.ts @@ -75,10 +75,10 @@ describe('PatrolIntelligenceHeader', () => { it('turns provider-blocked manual run controls into setup actions', () => { expect(headerSource).toContain('runBlockedByProviderSetup'); expect(headerSource).toContain("state.patrolReadiness()?.status === 'not_ready'"); - expect(headerSource).toContain('getPatrolProviderSettingsAction'); - expect(headerSource).toContain('providerSetupAction.href'); + expect(headerSource).toContain('getPatrolSetupAction'); + expect(headerSource).toContain('providerSetupAction().href'); expect(headerSource).toContain('Fix setup'); - expect(headerSource).toContain('Check Patrol model:'); + expect(headerSource).toContain("getPatrolSetupAction(state.patrolReadiness()?.cause)"); expect(headerSource).toContain('runButtonDisabled'); expect(headerSource).not.toContain('!state.canTriggerPatrol() ||'); }); diff --git a/frontend-modern/src/features/patrol/__tests__/PatrolIntelligenceWorkspace.test.ts b/frontend-modern/src/features/patrol/__tests__/PatrolIntelligenceWorkspace.test.ts index b99e931a8..24b4e7221 100644 --- a/frontend-modern/src/features/patrol/__tests__/PatrolIntelligenceWorkspace.test.ts +++ b/frontend-modern/src/features/patrol/__tests__/PatrolIntelligenceWorkspace.test.ts @@ -67,7 +67,8 @@ describe('PatrolIntelligenceWorkspace trust strip', () => { expect(workspaceSource).not.toContain(removedAllModeCopy); expect(workspaceSource).toContain('Patrol cannot run yet'); expect(workspaceSource).toContain('Once ready'); - expect(workspaceSource).toContain('getPatrolProviderSettingsAction'); + expect(workspaceSource).toContain('getPatrolSetupAction'); + expect(workspaceSource).toContain('getPatrolSetupHint'); expect(workspaceSource).toContain('state.patrolRunHistory.value()?.length'); expect(workspaceSource).not.toContain('showControls={!state.selectedRun() && !isSetupOnly()}'); expect(PATROL_WORKSPACE_SETUP_TITLE).toBe('Patrol needs setup'); diff --git a/frontend-modern/src/utils/__tests__/patrolRuntimeActions.test.ts b/frontend-modern/src/utils/__tests__/patrolRuntimeActions.test.ts index 05f875d54..d6e0a7005 100644 --- a/frontend-modern/src/utils/__tests__/patrolRuntimeActions.test.ts +++ b/frontend-modern/src/utils/__tests__/patrolRuntimeActions.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from 'vitest'; import { getPatrolProviderSettingsAction, + getPatrolSetupAction, + getPatrolSetupHint, PATROL_PROVIDER_SETTINGS_ACTION, } from '@/utils/patrolRuntimeActions'; @@ -15,4 +17,26 @@ describe('patrolRuntimeActions', () => { expect(action).toEqual(PATROL_PROVIDER_SETTINGS_ACTION); expect(action).not.toBe(PATROL_PROVIDER_SETTINGS_ACTION); }); + + it('routes config-level causes to Provider & Models instead of the model check', () => { + for (const cause of ['assistant_disabled', 'provider_not_configured']) { + expect(getPatrolSetupAction(cause)).toEqual({ + label: 'Open Provider & Models', + href: '/settings/pulse-intelligence/provider', + }); + } + }); + + it('keeps the model check action for model-level and unknown causes', () => { + for (const cause of ['model_not_selected', 'model_unsupported_tools', undefined, '']) { + expect(getPatrolSetupAction(cause)).toEqual(PATROL_PROVIDER_SETTINGS_ACTION); + } + }); + + it('suppresses the tool-check hint for config-level causes', () => { + expect(getPatrolSetupHint('assistant_disabled')).toBe(''); + expect(getPatrolSetupHint('provider_not_configured')).toBe(''); + expect(getPatrolSetupHint('model_not_selected')).toContain('run the model check'); + expect(getPatrolSetupHint(undefined)).toContain('run the model check'); + }); }); diff --git a/frontend-modern/src/utils/patrolRuntimeActions.ts b/frontend-modern/src/utils/patrolRuntimeActions.ts index c791e4bbc..93030420a 100644 --- a/frontend-modern/src/utils/patrolRuntimeActions.ts +++ b/frontend-modern/src/utils/patrolRuntimeActions.ts @@ -10,6 +10,27 @@ export const PATROL_PROVIDER_SETTINGS_ACTION: PatrolRuntimeActionPresentation = href: settingsTabPath('system-ai-patrol'), }; +// Config-level causes mean the install has no working provider at all, so +// sending the user to the Patrol model check is a dead end. Route them to +// Provider & Models, where the enable toggle and API key / Ollama fields live. +const PATROL_CONFIG_LEVEL_CAUSES = new Set(['assistant_disabled', 'provider_not_configured']); + export const getPatrolProviderSettingsAction = (): PatrolRuntimeActionPresentation => ({ ...PATROL_PROVIDER_SETTINGS_ACTION, }); + +export const getPatrolSetupAction = (cause?: string): PatrolRuntimeActionPresentation => { + if (cause && PATROL_CONFIG_LEVEL_CAUSES.has(cause)) { + return { label: 'Open Provider & Models', href: settingsTabPath('system-ai') }; + } + return { ...PATROL_PROVIDER_SETTINGS_ACTION }; +}; + +// The tool-check explainer only makes sense once a provider exists; for +// config-level causes the readiness summary already says what to do. +export const getPatrolSetupHint = (cause?: string): string => { + if (cause && PATROL_CONFIG_LEVEL_CAUSES.has(cause)) { + return ''; + } + return 'Open Patrol settings and run the model check. Provider connectivity can be healthy even when the selected model cannot use Patrol tools.'; +}; diff --git a/internal/ai/patrol_preflight.go b/internal/ai/patrol_preflight.go index 51b67caa2..7f3bc1c87 100644 --- a/internal/ai/patrol_preflight.go +++ b/internal/ai/patrol_preflight.go @@ -127,9 +127,9 @@ func (s *Service) RunPatrolToolPreflight(ctx context.Context, providerName, mode } if !cfg.Enabled { result.Cause = PatrolFailureCauseAssistantDisabled - result.Title = "Pulse Patrol: Assistant disabled" - result.Summary = "Pulse Assistant is not enabled" - result.Recommendation = "Enable Pulse Assistant in Assistant & Patrol settings, then re-run preflight." + result.Title = "Pulse Patrol: Pulse Intelligence turned off" + result.Summary = "Pulse Intelligence is turned off" + result.Recommendation = "Turn on Pulse Intelligence on the Provider & Models settings page, then run Check Patrol model again." result.DurationMs = time.Since(started).Milliseconds() s.recordPatrolPreflight(result, time.Now()) return result @@ -143,7 +143,7 @@ func (s *Service) RunPatrolToolPreflight(ctx context.Context, providerName, mode result.Cause = PatrolFailureCauseModelNotSelected result.Title = "Pulse Patrol: No model selected" result.Summary = "Patrol has no model selected" - result.Recommendation = "Select a Patrol model in Assistant & Patrol settings, then re-run preflight." + result.Recommendation = "Select a Patrol model in Patrol settings. If no models are listed, add a provider API key or an Ollama server on the Provider & Models settings page first." result.DurationMs = time.Since(started).Milliseconds() s.recordPatrolPreflight(result, time.Now()) return result diff --git a/internal/ai/patrol_readiness.go b/internal/ai/patrol_readiness.go index 5ddf9a15e..60a652397 100644 --- a/internal/ai/patrol_readiness.go +++ b/internal/ai/patrol_readiness.go @@ -51,10 +51,10 @@ func EvaluatePatrolConfigReadiness(cfg *config.AIConfig) PatrolConfigReadiness { return patrolConfigReadiness("", "", PatrolReadinessNotReady, PatrolFailureCauseSettingsPersistence, "Assistant & Patrol settings could not be loaded from persistence.") } if !cfg.Enabled { - return patrolConfigReadiness("", "", PatrolReadinessNotReady, PatrolFailureCauseAssistantDisabled, "Pulse Assistant is disabled, so Patrol cannot run model-backed verification.") + return patrolConfigReadiness("", "", PatrolReadinessNotReady, PatrolFailureCauseAssistantDisabled, "Pulse Intelligence is turned off, so Patrol cannot run.") } if !cfg.IsConfigured() { - return patrolConfigReadiness("", "", PatrolReadinessNotReady, PatrolFailureCauseProviderNotConfigured, "No AI provider is configured for Patrol.") + return patrolConfigReadiness("", "", PatrolReadinessNotReady, PatrolFailureCauseProviderNotConfigured, "No AI provider is configured yet. Add a provider API key or an Ollama server on the Provider & Models settings page.") } model := strings.TrimSpace(cfg.GetPatrolModel()) diff --git a/internal/api/ai_handlers.go b/internal/api/ai_handlers.go index 02e700932..fc38a6486 100644 --- a/internal/api/ai_handlers.go +++ b/internal/api/ai_handlers.go @@ -5115,13 +5115,13 @@ func (h *AISettingsHandler) buildPatrolReadiness(ctx context.Context, aiService addCheck("settings", patrolReadinessReady, ai.PatrolFailureCauseNone, "Settings persistence", "Assistant & Patrol settings are readable.", "") if !cfg.Enabled { - addCheck("enabled", patrolReadinessNotReady, ai.PatrolFailureCauseAssistantDisabled, "Assistant enabled", "Pulse Assistant is disabled, so Patrol cannot run model-backed verification.", "open_provider_settings") + addCheck("enabled", patrolReadinessNotReady, ai.PatrolFailureCauseAssistantDisabled, "Assistant enabled", "Pulse Intelligence is turned off, so Patrol cannot run.", "open_provider_settings") } else { addCheck("enabled", patrolReadinessReady, ai.PatrolFailureCauseNone, "Assistant enabled", "Pulse Assistant is enabled for Patrol verification.", "") } if !cfg.IsConfigured() { - addCheck("provider", patrolReadinessNotReady, ai.PatrolFailureCauseProviderNotConfigured, "Provider configured", "No AI provider is configured for Patrol.", "open_provider_settings") + addCheck("provider", patrolReadinessNotReady, ai.PatrolFailureCauseProviderNotConfigured, "Provider configured", "No AI provider is configured yet. Add a provider API key or an Ollama server on the Provider & Models settings page.", "open_provider_settings") return summarizePatrolReadiness("", "", checks) } addCheck("provider", patrolReadinessReady, ai.PatrolFailureCauseNone, "Provider configured", "At least one AI provider is configured.", "")