Skip to content

Commit 265c72d

Browse files
committed
Bugfix: Defer AI offer toast until onboarding ends
- Suppress the AI offer toast during the FDA onboarding modal so it doesn't pile on top of the prompt. Usability tests showed the two firing together overwhelmed users. - Mirror the updater's onboarded gate: `initAiState` seeds `aiState.onboarded` from `loadSettings().isOnboarded`, keeps an `Offer` status `hidden` while not onboarded, and remembers `pendingOffer` for later. - New `notifyAiOnboardingComplete()` is called from `+page.svelte`'s `handleFdaComplete()` (Allow + Deny paths) and from the two legacy fallbacks for users who already had FDA or denied before the `isOnboarded` flag existed. - Sticky merge in the seed (`aiState.onboarded || settings.isOnboarded`) keeps the gate open if the hook fires before `loadSettings()` resolves on the legacy paths. - Tests cover suppress-when-not-onboarded, surface-on-init, surface-via-hook, no-op without pending offer, idempotent across dismiss, install events flowing through, and the race regression.
1 parent c41d2e0 commit 265c72d

5 files changed

Lines changed: 241 additions & 2 deletions

File tree

apps/desktop/src/lib/ai/AiNotification.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ let mockState = {
2828
baseOverheadBytes: 3500000000,
2929
},
3030
downloadToastUserDismissed: false,
31+
onboarded: true,
32+
pendingOffer: false,
3133
}
3234

3335
function mountToast() {
@@ -52,6 +54,8 @@ describe('AiToastContent', () => {
5254
baseOverheadBytes: 3500000000,
5355
},
5456
downloadToastUserDismissed: false,
57+
onboarded: true,
58+
pendingOffer: false,
5559
}
5660
vi.mocked(getAiState).mockReturnValue(mockState)
5761
})

apps/desktop/src/lib/ai/CLAUDE.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,21 @@ Settings `ai.provider`, `ai.openaiApiKey`, `ai.openaiBaseUrl`, `ai.openaiModel`,
3131
`settings-registry.ts`. The main layout calls `configureAi(...)` after `initSettingsApplier()` to push config to
3232
backend.
3333

34+
### Onboarding gate suppresses the offer toast
35+
36+
While first-launch onboarding (the FDA prompt) is on screen, the AI offer toast is suppressed so it doesn't pile on top
37+
of the modal. `initAiState()` seeds `aiState.onboarded` from `loadSettings().isOnboarded`. When the backend reports
38+
`Offer` and `onboarded === false`, `updateNotificationFromStatus()` keeps `notificationState = 'hidden'` and sets
39+
`aiState.pendingOffer = true`.
40+
41+
`notifyAiOnboardingComplete()` is called from `routes/(main)/+page.svelte` whenever the FDA prompt closes (Allow or Deny
42+
path) or for legacy users who never saw the prompt. It flips `onboarded` and, if `pendingOffer` is true, surfaces the
43+
offer right then. The Allow path also restarts the app — on next launch `isOnboarded` is already true so the gate
44+
doesn't engage at all.
45+
46+
This mirrors the updater module's pattern in `$lib/updates/updater.svelte.ts` (`onboarded` + `notifyOnboardingComplete`)
47+
— same gate, same opening event, two independent toasts.
48+
3449
### 7-day dismissal, permanent opt-out
3550

3651
"Not now" hides offer for 7 days (`dismissedUntil` timestamp in state). "I don't want AI" sets `opted_out: true`

apps/desktop/src/lib/ai/ai-state.svelte.ts

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
type AiStatus,
1414
} from '$lib/tauri-commands'
1515
import { getSetting, setSetting } from '$lib/settings'
16+
import { loadSettings } from '$lib/settings-store'
1617

1718
type AiNotificationState = 'hidden' | 'offer' | 'downloading' | 'installing' | 'ready' | 'starting'
1819

@@ -27,6 +28,19 @@ interface AiStateData {
2728
* flag resets whenever a new download run starts (on transition into `'downloading'`).
2829
*/
2930
downloadToastUserDismissed: boolean
31+
/**
32+
* True once first-launch onboarding (FDA prompt) has finished. While false, an `Offer` status
33+
* from the backend is suppressed so the AI toast doesn't pile on top of the FDA modal. Seeded
34+
* from `loadSettings().isOnboarded` in `initAiState`; flipped by `notifyAiOnboardingComplete`.
35+
*/
36+
onboarded: boolean
37+
/**
38+
* True when the backend reported `Offer` but the toast was suppressed because onboarding wasn't
39+
* complete. `notifyAiOnboardingComplete` reads this to surface the offer once the gate opens.
40+
* Cleared by user-driven exits from the offer (`dismiss`, `optOut`, `download`) so the gate
41+
* doesn't resurrect a decision the user has already made.
42+
*/
43+
pendingOffer: boolean
3044
}
3145

3246
const aiState = $state<AiStateData>({
@@ -35,6 +49,8 @@ const aiState = $state<AiStateData>({
3549
progressText: '',
3650
modelInfo: null,
3751
downloadToastUserDismissed: false,
52+
onboarded: false,
53+
pendingOffer: false,
3854
})
3955

4056
export function getAiState(): AiStateData {
@@ -48,6 +64,8 @@ export function resetForTesting(): void {
4864
aiState.progressText = ''
4965
aiState.modelInfo = null
5066
aiState.downloadToastUserDismissed = false
67+
aiState.onboarded = false
68+
aiState.pendingOffer = false
5169
}
5270

5371
/** Marks the downloading toast as user-dismissed for the current download run. */
@@ -62,6 +80,17 @@ export async function initAiState(): Promise<() => void> {
6280
return () => {}
6381
}
6482

83+
// Seed the onboarded gate from persisted settings. While `false`, an Offer status stays
84+
// hidden — the FDA modal owns the screen during first launch. Returning users (isOnboarded
85+
// already true) skip the gate entirely.
86+
//
87+
// Sticky merge instead of plain assignment: `+page.svelte` may have already called
88+
// `notifyAiOnboardingComplete()` while `loadSettings()` was in flight (legacy fallback paths
89+
// run no user input gate). A plain assignment would overwrite the hook's `true` back to a
90+
// stale `false` if disk hadn't synced yet.
91+
const settings = await loadSettings()
92+
aiState.onboarded = aiState.onboarded || settings.isOnboarded
93+
6594
// Fetch model info and status in parallel
6695
const [status, modelInfo] = await Promise.all([getAiStatus(), getAiModelInfo()])
6796
aiState.modelInfo = modelInfo
@@ -143,13 +172,37 @@ function updateNotificationFromStatus(status: AiStatus): void {
143172
aiState.notificationState = 'hidden' // Already installed, don't show anything
144173
break
145174
case 'offer':
146-
aiState.notificationState = 'offer'
175+
// Suppress the offer until first-launch onboarding is done. The pending flag lets
176+
// `notifyAiOnboardingComplete` surface it the moment the gate opens.
177+
if (aiState.onboarded) {
178+
aiState.notificationState = 'offer'
179+
aiState.pendingOffer = false
180+
} else {
181+
aiState.notificationState = 'hidden'
182+
aiState.pendingOffer = true
183+
}
147184
break
148185
default:
149186
aiState.notificationState = 'hidden'
150187
}
151188
}
152189

190+
/**
191+
* Marks first-launch onboarding as complete so a deferred AI offer can finally surface.
192+
* Called from `routes/(main)/+page.svelte` once the FDA prompt closes (Allow or Deny path).
193+
*
194+
* The Allow path lands on `isOnboarded: true` via `notifyOnboardingComplete()`, so the next
195+
* launch reads onboarded=true and skips the gate entirely. The Deny path needs this hook to
196+
* reveal the offer in the same session.
197+
*/
198+
export function notifyAiOnboardingComplete(): void {
199+
aiState.onboarded = true
200+
if (aiState.pendingOffer) {
201+
aiState.notificationState = 'offer'
202+
aiState.pendingOffer = false
203+
}
204+
}
205+
153206
function formatProgressText(progress: AiDownloadProgress): string {
154207
if (progress.totalBytes === 0) return 'Starting download...'
155208
const percent = Math.round((progress.bytesDownloaded / progress.totalBytes) * 100)

apps/desktop/src/lib/ai/ai-state.test.ts

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,20 @@ vi.mock('$lib/tauri-commands', async () => {
1515
}
1616
})
1717

18+
vi.mock('$lib/settings-store', () => ({
19+
loadSettings: vi.fn(),
20+
}))
21+
1822
import { getAiStatus, getAiModelInfo, startAiDownload, cancelAiDownload, dismissAiOffer } from '$lib/tauri-commands'
23+
import { loadSettings } from '$lib/settings-store'
1924
import {
2025
getAiState,
2126
initAiState,
2227
handleDownload,
2328
handleCancel,
2429
handleDismiss,
2530
handleGotIt,
31+
notifyAiOnboardingComplete,
2632
resetForTesting,
2733
} from './ai-state.svelte'
2834

@@ -35,10 +41,24 @@ const mockModelInfo = {
3541
baseOverheadBytes: 3500000000,
3642
}
3743

44+
const ONBOARDED = {
45+
showHiddenFiles: true,
46+
fullDiskAccessChoice: 'allow' as const,
47+
isOnboarded: true,
48+
}
49+
50+
const NOT_ONBOARDED = {
51+
showHiddenFiles: true,
52+
fullDiskAccessChoice: 'notAskedYet' as const,
53+
isOnboarded: false,
54+
}
55+
3856
describe('ai-state', () => {
3957
beforeEach(() => {
4058
vi.clearAllMocks()
4159
resetForTesting()
60+
// Default: existing tests assume the user is past onboarding so the offer surfaces.
61+
vi.mocked(loadSettings).mockResolvedValue(ONBOARDED)
4262
})
4363

4464
describe('getAiState', () => {
@@ -266,4 +286,140 @@ describe('ai-state', () => {
266286
expect(state.downloadProgress).toBeNull()
267287
})
268288
})
289+
290+
describe('onboarding gate', () => {
291+
it('suppresses Offer when isOnboarded is false', async () => {
292+
vi.mocked(loadSettings).mockResolvedValue(NOT_ONBOARDED)
293+
vi.mocked(getAiStatus).mockResolvedValue('offer')
294+
vi.mocked(getAiModelInfo).mockResolvedValue(mockModelInfo)
295+
296+
await initAiState()
297+
298+
const state = getAiState()
299+
expect(state.notificationState).toBe('hidden')
300+
expect(state.pendingOffer).toBe(true)
301+
expect(state.onboarded).toBe(false)
302+
})
303+
304+
it('surfaces Offer immediately when isOnboarded is true at init', async () => {
305+
vi.mocked(loadSettings).mockResolvedValue(ONBOARDED)
306+
vi.mocked(getAiStatus).mockResolvedValue('offer')
307+
vi.mocked(getAiModelInfo).mockResolvedValue(mockModelInfo)
308+
309+
await initAiState()
310+
311+
const state = getAiState()
312+
expect(state.notificationState).toBe('offer')
313+
expect(state.pendingOffer).toBe(false)
314+
expect(state.onboarded).toBe(true)
315+
})
316+
317+
it('surfaces a deferred Offer when notifyAiOnboardingComplete is called', async () => {
318+
vi.mocked(loadSettings).mockResolvedValue(NOT_ONBOARDED)
319+
vi.mocked(getAiStatus).mockResolvedValue('offer')
320+
vi.mocked(getAiModelInfo).mockResolvedValue(mockModelInfo)
321+
322+
await initAiState()
323+
expect(getAiState().notificationState).toBe('hidden')
324+
325+
notifyAiOnboardingComplete()
326+
327+
const state = getAiState()
328+
expect(state.notificationState).toBe('offer')
329+
expect(state.pendingOffer).toBe(false)
330+
expect(state.onboarded).toBe(true)
331+
})
332+
333+
it('does not flip state to Offer when there is no pending offer', async () => {
334+
// Backend says Available — model already installed, no offer to surface.
335+
vi.mocked(loadSettings).mockResolvedValue(NOT_ONBOARDED)
336+
vi.mocked(getAiStatus).mockResolvedValue('available')
337+
vi.mocked(getAiModelInfo).mockResolvedValue(mockModelInfo)
338+
339+
await initAiState()
340+
expect(getAiState().notificationState).toBe('hidden')
341+
342+
notifyAiOnboardingComplete()
343+
344+
const state = getAiState()
345+
expect(state.notificationState).toBe('hidden')
346+
expect(state.onboarded).toBe(true)
347+
})
348+
349+
it('is idempotent — second call does not re-trigger the offer', async () => {
350+
vi.mocked(loadSettings).mockResolvedValue(NOT_ONBOARDED)
351+
vi.mocked(getAiStatus).mockResolvedValue('offer')
352+
vi.mocked(getAiModelInfo).mockResolvedValue(mockModelInfo)
353+
354+
await initAiState()
355+
notifyAiOnboardingComplete()
356+
// User dismisses the offer.
357+
vi.mocked(dismissAiOffer).mockResolvedValue(undefined)
358+
await handleDismiss()
359+
expect(getAiState().notificationState).toBe('hidden')
360+
361+
// A second onboarding-complete signal must NOT resurrect the dismissed offer.
362+
notifyAiOnboardingComplete()
363+
364+
expect(getAiState().notificationState).toBe('hidden')
365+
})
366+
367+
it('does not regress onboarded=true if notifyAiOnboardingComplete fires while loadSettings is in flight', async () => {
368+
// Race: legacy fallback paths in `+page.svelte` (hasFda + !isOnboarded, deny + !isOnboarded)
369+
// call `notifyAiOnboardingComplete()` without any user gate. If `initAiState` is still
370+
// awaiting `loadSettings()` when that hook fires — and disk hasn't synced the
371+
// `notifyOnboardingComplete()` save yet — a plain assignment in initAiState would clobber
372+
// the hook's `onboarded = true` back to a stale `false`, leaving the offer permanently gated.
373+
let resolveSettings: ((value: typeof NOT_ONBOARDED) => void) | undefined
374+
vi.mocked(loadSettings).mockImplementation(
375+
() =>
376+
new Promise<typeof NOT_ONBOARDED>((resolve) => {
377+
resolveSettings = resolve
378+
}),
379+
)
380+
vi.mocked(getAiStatus).mockResolvedValue('offer')
381+
vi.mocked(getAiModelInfo).mockResolvedValue(mockModelInfo)
382+
383+
const initPromise = initAiState()
384+
385+
// Simulate the legacy fallback firing the hook before loadSettings resolves.
386+
notifyAiOnboardingComplete()
387+
expect(getAiState().onboarded).toBe(true)
388+
389+
// Now resolve loadSettings with stale `isOnboarded: false` (disk not yet synced).
390+
resolveSettings?.(NOT_ONBOARDED)
391+
await initPromise
392+
393+
const state = getAiState()
394+
expect(state.onboarded).toBe(true)
395+
// Status is `offer` and onboarded is sticky-true, so the offer surfaces directly.
396+
expect(state.notificationState).toBe('offer')
397+
expect(state.pendingOffer).toBe(false)
398+
})
399+
400+
it('does not gate downloading state — install events flow through even when not onboarded', async () => {
401+
// Edge case: user starts onboarding, app keeps running, backend somehow emits installing/ready.
402+
// The gate only suppresses the initial Offer, not in-flight install signals (which can only
403+
// arise after the user accepted the offer somewhere).
404+
vi.mocked(loadSettings).mockResolvedValue(NOT_ONBOARDED)
405+
vi.mocked(getAiStatus).mockResolvedValue('offer')
406+
vi.mocked(getAiModelInfo).mockResolvedValue(mockModelInfo)
407+
let installingCallback: (() => void) | undefined
408+
let completeCallback: (() => void) | undefined
409+
vi.mocked(listen).mockImplementation((event, callback) => {
410+
if (event === 'ai-installing') installingCallback = callback as () => void
411+
if (event === 'ai-install-complete') completeCallback = callback as () => void
412+
return Promise.resolve(() => {})
413+
})
414+
415+
await initAiState()
416+
expect(getAiState().notificationState).toBe('hidden')
417+
418+
installingCallback?.()
419+
expect(getAiState().notificationState).toBe('installing')
420+
421+
completeCallback?.()
422+
expect(getAiState().notificationState).toBe('ready')
423+
})
424+
})
269425
})

apps/desktop/src/routes/(main)/+page.svelte

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
import { SOFT_DIALOG_REGISTRY } from '$lib/ui/dialog-registry'
2929
import { loadSettings, saveSettings } from '$lib/settings-store'
3030
import { notifyOnboardingComplete, setFdaPromptShowing } from '$lib/updates/updater.svelte'
31+
import { notifyAiOnboardingComplete } from '$lib/ai/ai-state.svelte'
3132
import { openSettingsWindow } from '$lib/settings/settings-window'
3233
import { openFileViewer } from '$lib/file-viewer/open-viewer'
3334
import {
@@ -401,6 +402,7 @@
401402
// not yet flagged as onboarded, mark them so now (and unblock the update toast).
402403
if (!settings.isOnboarded) {
403404
await notifyOnboardingComplete()
405+
notifyAiOnboardingComplete()
404406
}
405407
showApp = true
406408
} else if (settings.fullDiskAccessChoice === 'notAskedYet') {
@@ -413,7 +415,13 @@
413415
fdaWasRevoked = true
414416
setFdaPromptShowing(true)
415417
} else {
416-
// User explicitly denied - proceed without prompting
418+
// User explicitly denied - proceed without prompting. Cover legacy users who denied
419+
// before the `isOnboarded` flag existed so the AI offer (and update toast) aren't
420+
// permanently gated.
421+
if (!settings.isOnboarded) {
422+
await notifyOnboardingComplete()
423+
notifyAiOnboardingComplete()
424+
}
417425
showApp = true
418426
}
419427
@@ -488,6 +496,9 @@
488496
// Mark onboarding as complete and (if an update is already ready) trigger the deferred toast.
489497
// notifyOnboardingComplete persists `isOnboarded: true` itself — no double-save needed.
490498
void notifyOnboardingComplete()
499+
// Surface a deferred AI offer too. Same gate, same opening event — without this, the offer
500+
// would only show on next launch, even on the Deny path where the user stays in the session.
501+
notifyAiOnboardingComplete()
491502
}
492503
493504
function handleExpirationModalClose() {

0 commit comments

Comments
 (0)