fix: track onboarding completion separately, extend popover to 30s

Replace EXOHasLaunchedBefore (set immediately on launch) with
EXOOnboardingCompleted (set only when user interacts with the popout).
This ensures onboarding re-triggers on reinstall if never completed.

Extend auto-open timer from 5s to 30s and remove auto-dismiss so the
popover persists until the user clicks Open Dashboard or X.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
This commit is contained in:
Alex Cheema
2026-02-21 11:07:45 -08:00
co-authored by Claude Opus 4.6
parent 5db9030535
commit 5e4531bc8b
3 changed files with 36 additions and 12 deletions
+3
View File
@@ -68,6 +68,9 @@ struct EXOApp: App {
.environmentObject(settingsWindowController)
.onReceive(controller.$isFirstLaunchReady) { ready in
if ready {
firstLaunchPopout.onComplete = { [weak controller] in
controller?.markOnboardingCompleted()
}
firstLaunchPopout.show()
}
}
+14 -4
View File
@@ -5,7 +5,7 @@ import Foundation
private let customNamespaceKey = "EXOCustomNamespace"
private let hfTokenKey = "EXOHFToken"
private let enableImageModelsKey = "EXOEnableImageModels"
private let hasLaunchedBeforeKey = "EXOHasLaunchedBefore"
private let onboardingCompletedKey = "EXOOnboardingCompleted"
@MainActor
final class ExoProcessController: ObservableObject {
@@ -118,9 +118,8 @@ final class ExoProcessController: ObservableObject {
process = child
status = .running
// Detect first-ever launch to trigger welcome popout
if !UserDefaults.standard.bool(forKey: hasLaunchedBeforeKey) {
UserDefaults.standard.set(true, forKey: hasLaunchedBeforeKey)
// Show welcome popout if onboarding was never completed
if !UserDefaults.standard.bool(forKey: onboardingCompletedKey) {
isFirstLaunchReady = true
}
} catch {
@@ -174,6 +173,17 @@ final class ExoProcessController: ObservableObject {
launch()
}
/// Mark onboarding as completed (user interacted with the welcome popout).
func markOnboardingCompleted() {
UserDefaults.standard.set(true, forKey: onboardingCompletedKey)
}
/// Reset onboarding so the welcome popout appears on next launch.
func resetOnboarding() {
UserDefaults.standard.removeObject(forKey: onboardingCompletedKey)
isFirstLaunchReady = false
}
func scheduleLaunch(after seconds: TimeInterval) {
cancelPendingLaunch()
let start = max(1, Int(ceil(seconds)))
+19 -8
View File
@@ -9,16 +9,22 @@ final class FirstLaunchPopout {
private var countdownTask: Task<Void, Never>?
private static let dashboardURL = "http://localhost:52415/"
/// Called when the user completes onboarding (clicks Open Dashboard or dismisses).
var onComplete: (() -> Void)?
func show() {
guard panel == nil else { return }
let hostingView = NSHostingView(
rootView: WelcomeCalloutView(
countdownDuration: 30,
onDismiss: { [weak self] in
self?.onComplete?()
self?.dismiss()
},
onOpen: { [weak self] in
self?.openDashboard()
self?.onComplete?()
self?.dismiss()
}))
hostingView.frame = NSRect(x: 0, y: 0, width: 280, height: 100)
@@ -60,15 +66,12 @@ final class FirstLaunchPopout {
window.animator().alphaValue = 1
}
// Auto-open dashboard after 5s then dismiss
// Auto-open dashboard after 30s but keep popout visible until user interacts
countdownTask = Task {
try? await Task.sleep(nanoseconds: 5_000_000_000)
try? await Task.sleep(nanoseconds: 30_000_000_000)
if !Task.isCancelled {
openDashboard()
try? await Task.sleep(nanoseconds: 800_000_000)
if !Task.isCancelled {
dismiss()
}
onComplete?()
}
}
}
@@ -99,12 +102,20 @@ final class FirstLaunchPopout {
/// Minimal welcome callout — friendly pointer, not a wall of text.
private struct WelcomeCalloutView: View {
let countdownDuration: Int
let onDismiss: () -> Void
let onOpen: () -> Void
@State private var countdown = 5
@State private var countdown: Int
@State private var timerTask: Task<Void, Never>?
@State private var appeared = false
init(countdownDuration: Int, onDismiss: @escaping () -> Void, onOpen: @escaping () -> Void) {
self.countdownDuration = countdownDuration
self.onDismiss = onDismiss
self.onOpen = onOpen
self._countdown = State(initialValue: countdownDuration)
}
var body: some View {
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .top) {
@@ -142,7 +153,7 @@ private struct WelcomeCalloutView: View {
Spacer()
if countdown > 0 {
Text("Opening in \(countdown)s")
Text("Auto-opens in \(countdown)s")
.font(.system(.caption2, design: .default))
.foregroundColor(.secondary.opacity(0.6))
.monospacedDigit()