Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions docs/issues/onboarding-provider-mcp-handoff/plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Implementation Plan

## Cause

There are two independent fragility points on the renderer side. Both surface
in packaged builds because timing in production is less forgiving than in dev.

1. `useGuidedOnboardingStep.setStepStatus` returns the previous (possibly
`null`) value of its internal `onboardingState` ref when the backend IPC
throws. `continueGuidedOnboardingFromSettings` then resolves a `null` step
id, hits the fallback branch, and calls `windowPresenter.focusMainWindow()`
instead of `router.push({ name: 'settings-mcp' })`. The backend state is
already correct — the renderer just doesn't see it on the relevant tick.

2. `GuidedOnboardingOverlay` always renders the dim `<path>` from
`OnBoardingSpotlight`, even when `useOnBoarding` has not produced a
spotlight rect yet (target element not yet sized). With no cutout the path
covers the entire viewport with `pointer-events: auto`, producing the
"full-window dim, no popover, can't click" symptom while the layout
stabilizes.

## Change

- **Renderer composable resilience.** In `useGuidedOnboardingStep`, when an
onboarding IPC call fails, fall back to fetching fresh state via
`onboardingClient.getState()` before returning to the caller. Apply to
`setStepStatus`, `activateStep`, and `forceComplete` paths.
- **Navigation helper resilience.** `continueGuidedOnboardingFromSettings`
refreshes its `state` from `onboardingClient.getState()` when the caller
passes a `null`/stale value, so that a transient renderer hiccup cannot
force the helper into the "focus main window" branch.
- **Overlay defensive rendering.** `OnBoardingSpotlight` only renders its
dim `<path>` when a cutout is present. With no cutout the parent overlay
still allows the panel to render at its fallback coordinates, but the
blocking dim no longer covers the window.

## Validation

- `pnpm run format`
- `pnpm run i18n`
- `pnpm run lint`
- `pnpm run typecheck`
39 changes: 39 additions & 0 deletions docs/issues/onboarding-provider-mcp-handoff/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Onboarding Provider → MCP Handoff

## Problem

In packaged builds, after the user finishes the `provider-model` guided step the
settings window does not automatically advance to the `settings-mcp` route, so
the MCP coachmark never appears in the expected sequence. Reopening the
settings window and clicking the MCP tab afterwards does surface the MCP
overlay, but in this fallback path the overlay renders as a full-window dim
without a visible popover, blocking subsequent interaction.

Locally (dev) the same flow is continuous. The divergence is timing- and
device-sensitive — the user could not reproduce on their own machine but
observed it on another machine.

## User Story

As a first-time user completing the provider step in the packaged app, I want
the guide to continue into the MCP step without me having to navigate manually,
and when the MCP overlay does appear I want to be able to read it and click
through it.

## Acceptance Criteria

- After `provider-model` completes, the settings window advances to
`settings-mcp` even when the per-step state returned from the backend is
stale or missing, as long as the backend has actually progressed.
- When the guided onboarding overlay is asked to render but the spotlight
target element is not yet sized, the dim layer does not cover the window —
no interaction is blocked while the target is still being laid out.
- Existing behavior is preserved: when the target element is sized the dim and
cutout render as before and the user-facing copy/keys do not change.

## Non-goals

- No change to the backend step ordering or migration logic in
`onboardingRouteSupport.ts`.
- No redesign of the onboarding panel layout or copy.
- No change to the welcome page / main-window flow.
7 changes: 7 additions & 0 deletions docs/issues/onboarding-provider-mcp-handoff/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Tasks

- [x] Add SDD artifacts.
- [x] Harden `useGuidedOnboardingStep` IPC failure paths with a `getState` fallback.
- [x] Refresh `state` inside `continueGuidedOnboardingFromSettings` when caller passes a null/stale value.
- [x] Stop rendering the dim path in `OnBoardingSpotlight` when there is no cutout.
- [x] Run `pnpm run format`, `pnpm run i18n`, `pnpm run lint`, and `pnpm run typecheck`.
20 changes: 18 additions & 2 deletions src/renderer/settings/lib/guidedOnboardingSettings.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { GuidedOnboardingState, GuidedOnboardingStepId } from '@shared/contracts/routes'
import { resolveGuidedOnboardingStepTarget } from '@shared/guidedOnboarding'
import { createOnboardingClient } from '@api/OnboardingClient'
import { persistGuidedOnboardingResumeIntent } from '@/lib/onboardingResume'
import type { Router } from 'vue-router'

Expand Down Expand Up @@ -28,8 +29,23 @@ export async function continueGuidedOnboardingFromSettings(options: {
focusMainWindow?: () => Promise<boolean> | boolean
}
}) {
const { state, router, currentRoute, windowPresenter } = options
const stepId = resolveGuidedOnboardingResumeStepId(state)
const { router, currentRoute, windowPresenter } = options
let { state } = options
let stepId = resolveGuidedOnboardingResumeStepId(state)

// If the caller passed a stale/null state, the local handler likely failed
// its IPC call (or never received a response). Re-read from the backend so a
// transient renderer hiccup cannot force the helper into the fallback branch
// that focuses the main window instead of advancing within settings.
if (!stepId) {
try {
state = await createOnboardingClient().getState()
stepId = resolveGuidedOnboardingResumeStepId(state)
} catch (error) {
console.warn('[GuidedOnboarding] Failed to refresh state from backend:', error)
}
}

const target = resolveGuidedOnboardingStepTarget(stepId)

if (target?.surface === 'settings' && target.routeName) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
focusable="false"
>
<path
v-if="cutoutPathD"
data-testid="onboarding-spotlight-path"
:d="pathD"
:fill="fillColor"
Expand Down
19 changes: 16 additions & 3 deletions src/renderer/src/composables/useGuidedOnboardingStep.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,19 @@ export function useGuidedOnboardingStep(stepId: GuidedOnboardingStepId) {
}
}

const recoverStateFromBackend = async (
context: string
): Promise<GuidedOnboardingState | null> => {
try {
const refreshed = await onboardingClient.getState()
onboardingState.value = refreshed
return refreshed
} catch (error) {
console.warn(`[GuidedOnboarding] Failed to recover state after ${context}:`, error)
return onboardingState.value
}
}

const dismissGuide = () => {
dismissed.value = true
}
Expand All @@ -91,7 +104,7 @@ export function useGuidedOnboardingStep(stepId: GuidedOnboardingStepId) {
return finalizeIfNeeded(onboardingState.value)
} catch (error) {
console.warn(`[GuidedOnboarding] Failed to set step ${stepId} status to ${status}:`, error)
return onboardingState.value
return recoverStateFromBackend(`setStepStatus(${stepId}, ${status})`)
}
}

Expand All @@ -103,7 +116,7 @@ export function useGuidedOnboardingStep(stepId: GuidedOnboardingStepId) {
return onboardingState.value
} catch (error) {
console.warn(`[GuidedOnboarding] Failed to activate step ${targetStepId}:`, error)
return onboardingState.value
return recoverStateFromBackend(`activateStep(${targetStepId})`)
}
}

Expand Down Expand Up @@ -131,7 +144,7 @@ export function useGuidedOnboardingStep(stepId: GuidedOnboardingStepId) {
return onboardingState.value
} catch (error) {
console.warn(`[GuidedOnboarding] Failed to force complete onboarding from ${stepId}:`, error)
return onboardingState.value
return recoverStateFromBackend(`forceComplete(${stepId})`)
}
}

Expand Down