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.
This commit is contained in:
rcourtman 2026-07-10 11:38:23 +01:00
parent 0e40ec07cb
commit cc948b022c
11 changed files with 107 additions and 40 deletions

View file

@ -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<{
<Show
when={selectableModels().length > 0}
fallback={
<input
type="text"
value={selectedModel()}
onInput={(e) => setSelectedModel(e.currentTarget.value)}
placeholder="Use shared default model"
aria-label={config().ariaLabel}
class={controlClass()}
disabled={state.saving()}
/>
<Show
when={state.hasConfiguredProvider()}
fallback={
<p class="rounded-md border border-border bg-surface-alt px-3 py-2 text-xs text-base-content">
No AI provider is configured yet. Add an API key or an Ollama server on{' '}
<A
href={settingsTabPath('system-ai')}
class="font-medium text-blue-600 underline dark:text-blue-400"
>
Provider & Models
</A>
, then pick a model here.
</p>
}
>
<input
type="text"
value={selectedModel()}
onInput={(e) => setSelectedModel(e.currentTarget.value)}
placeholder="Use shared default model"
aria-label={config().ariaLabel}
class={controlClass()}
disabled={state.saving()}
/>
</Show>
}
>
<AIModelPicker

View file

@ -9,7 +9,7 @@ import { getUpgradeActionDestination } from '@/stores/licenseCommercial';
import { presentationPolicyHidesUpgradePrompts } from '@/stores/sessionPresentationPolicy';
import { formatRelativeTime } from '@/utils/format';
import { getAIProviderDisplayName } from '@/utils/aiProviderPresentation';
import { PATROL_PROVIDER_SETTINGS_ACTION } from '@/utils/patrolRuntimeActions';
import { getPatrolSetupAction } from '@/utils/patrolRuntimeActions';
import { getPatrolAutonomyAvailabilityPresentation } from './patrolAutonomyAvailability';
import type { PatrolIntelligenceState } from './usePatrolIntelligenceState';
@ -229,11 +229,11 @@ export function PatrolIntelligenceBanners(props: { state: PatrolIntelligenceStat
</div>
<Show when={shouldShowReadinessAction()}>
<a
href={PATROL_PROVIDER_SETTINGS_ACTION.href}
href={getPatrolSetupAction(state.patrolReadiness()?.cause).href}
class="inline-flex items-center justify-center gap-2 px-3 py-1.5 text-xs font-semibold rounded-md border border-amber-200 bg-amber-100 text-amber-900 transition-colors hover:bg-amber-200 dark:border-amber-700 dark:bg-amber-900 dark:text-amber-100 dark:hover:bg-amber-900"
>
<SettingsIcon class="w-3.5 h-3.5" />
{PATROL_PROVIDER_SETTINGS_ACTION.label}
{getPatrolSetupAction(state.patrolReadiness()?.cause).label}
</a>
</Show>
</div>
@ -261,11 +261,11 @@ export function PatrolIntelligenceBanners(props: { state: PatrolIntelligenceStat
</div>
<div class="flex items-center gap-2">
<a
href={PATROL_PROVIDER_SETTINGS_ACTION.href}
href={getPatrolSetupAction(state.patrolReadiness()?.cause).href}
class="inline-flex items-center justify-center gap-2 px-3 py-1.5 text-xs font-semibold text-amber-900 dark:text-amber-100 bg-amber-100 dark:bg-amber-900 border border-amber-200 dark:border-amber-700 rounded-md hover:bg-amber-200 dark:hover:bg-amber-900 transition-colors"
>
<SettingsIcon class="w-3.5 h-3.5" />
{PATROL_PROVIDER_SETTINGS_ACTION.label}
{getPatrolSetupAction(state.patrolReadiness()?.cause).label}
</a>
</div>
</div>

View file

@ -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={
<A
href={providerSetupAction.href}
aria-label={`Check Patrol model: ${state.triggerPatrolDisabledReason() || 'Patrol model needs attention'}`}
title={state.triggerPatrolDisabledReason() || 'Open Patrol settings'}
href={providerSetupAction().href}
aria-label={`${providerSetupAction().label}: ${state.triggerPatrolDisabledReason() || 'Patrol setup needs attention'}`}
title={state.triggerPatrolDisabledReason() || providerSetupAction().label}
class={className}
>
<SettingsIcon class="w-4 h-4" />

View file

@ -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
<h3 class="text-sm font-semibold">Patrol cannot run yet</h3>
</div>
<p class="mt-2 text-sm leading-6 text-base-content">{setupReason()}</p>
<p class="mt-1 text-xs leading-5 text-muted">
Open Patrol settings and run the model check. Provider connectivity can be healthy
even when the selected model cannot use Patrol tools.
</p>
<Show when={setupHint()}>
<p class="mt-1 text-xs leading-5 text-muted">{setupHint()}</p>
</Show>
<ButtonLink
href={setupAction.href}
href={setupAction().href}
variant="primary"
size="sm"
class="mt-4 gap-1.5"
>
<SettingsIcon class="h-4 w-4" aria-hidden="true" />
{setupAction.label}
{setupAction().label}
</ButtonLink>
</div>
<div class="rounded-md border border-amber-200 bg-surface/80 p-3 dark:border-amber-900">

View file

@ -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() ||');
});

View file

@ -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');

View file

@ -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');
});
});

View file

@ -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.';
};

View file

@ -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

View file

@ -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())

View file

@ -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.", "")