diff --git a/clients/macos/vellum-assistant/Features/Onboarding/FirstMeeting/FirstMeetingFlowView.swift b/clients/macos/vellum-assistant/Features/Onboarding/FirstMeeting/FirstMeetingFlowView.swift new file mode 100644 index 00000000000..c5c6b98c1cb --- /dev/null +++ b/clients/macos/vellum-assistant/Features/Onboarding/FirstMeeting/FirstMeetingFlowView.swift @@ -0,0 +1,160 @@ +import SwiftUI + +@MainActor +struct FirstMeetingFlowView: View { + @Bindable var state: OnboardingState + let daemonClient: DaemonClientProtocol + var onComplete: () -> Void + var onOpenSettings: () -> Void + + var body: some View { + ZStack { + VColor.background + .ignoresSafeArea() + + // Dimmed mock chrome — gives the "chat UI behind" effect + VStack(spacing: 0) { + mockToolbar + Spacer() + mockInputBar + } + .opacity(0.25) + .allowsHitTesting(false) + + // Vertical card layout + VStack(spacing: 0) { + // TOP: Meadow background + ZStack { + MeadowBackground() + } + .frame(height: 350) + .clipped() + + // BOTTOM: Dark content panel + VStack(spacing: VSpacing.lg) { + Group { + switch state.currentStep { + case 0: + Text("Egg step — coming soon") + .font(VFont.headline) + .foregroundColor(VColor.textPrimary) + case 1: + Text("Hatch step — coming soon") + .font(VFont.headline) + .foregroundColor(VColor.textPrimary) + case 2: + Text("Introduction — coming soon") + .font(VFont.headline) + .foregroundColor(VColor.textPrimary) + case 3: + Text("Capabilities briefing — coming soon") + .font(VFont.headline) + .foregroundColor(VColor.textPrimary) + case 4: + Text("Observation mode — coming soon") + .font(VFont.headline) + .foregroundColor(VColor.textPrimary) + default: + EmptyView() + } + } + .transition( + .asymmetric( + insertion: .opacity.combined(with: .offset(y: 12)), + removal: .opacity.combined(with: .offset(y: -8)) + ) + ) + .id(state.currentStep) + + OnboardingProgressDots(currentStep: state.currentStep) + .padding(.top, VSpacing.xs) + } + .padding(.horizontal, VSpacing.xxl) + .padding(.top, VSpacing.xl) + .padding(.bottom, VSpacing.xxl) + .frame(maxWidth: .infinity) + .background( + Rectangle() + .fill(.ultraThinMaterial) + .overlay( + Rectangle() + .fill(Meadow.panelBackground) + ) + ) + } + .frame(maxWidth: 640) + .clipShape(RoundedRectangle(cornerRadius: VRadius.xl)) + .overlay( + RoundedRectangle(cornerRadius: VRadius.xl) + .stroke(Meadow.panelBorder, lineWidth: 1) + ) + .shadow(color: .black.opacity(0.4), radius: 24, y: 12) + .padding(.vertical, VSpacing.xxxl) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + // MARK: - Mock Chrome + + private var mockToolbar: some View { + HStack { + HStack(spacing: 6) { + Image(systemName: "bubble.left") + .font(.system(size: 11)) + Text("Chat") + .font(VFont.bodyMedium) + } + .foregroundColor(VColor.textPrimary) + .padding(.horizontal, VSpacing.md) + .padding(.vertical, VSpacing.sm) + .background(VColor.surface.opacity(0.5)) + .clipShape(RoundedRectangle(cornerRadius: VRadius.md)) + + Spacer() + + HStack(spacing: VSpacing.sm) { + ForEach(["Automated", "Agent", "Control", "System"], id: \.self) { tab in + HStack(spacing: 4) { + Image(systemName: "circle") + .font(.system(size: 7)) + Text(tab) + } + .font(VFont.caption) + .foregroundColor(VColor.textMuted) + .padding(.horizontal, VSpacing.md) + .padding(.vertical, VSpacing.sm) + .background(VColor.surface.opacity(0.3)) + .clipShape(RoundedRectangle(cornerRadius: VRadius.md)) + } + } + } + .padding(.horizontal, VSpacing.lg) + .padding(.top, 44) // below titlebar + } + + private var mockInputBar: some View { + HStack(spacing: VSpacing.md) { + VCircleButton(icon: "phone.fill", label: "Phone", fillColor: Emerald._600.opacity(0.5)) { } + + Text("What you need chef?") + .font(VFont.body) + .foregroundColor(VColor.textMuted) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, VSpacing.lg) + .padding(.vertical, VSpacing.md) + .background(VColor.surface.opacity(0.3)) + .clipShape(RoundedRectangle(cornerRadius: VRadius.lg)) + } + .padding(.horizontal, VSpacing.lg) + .padding(.bottom, VSpacing.lg) + } +} + +#Preview { + FirstMeetingFlowView( + state: OnboardingState(), + daemonClient: DaemonClient(), + onComplete: {}, + onOpenSettings: {} + ) +} diff --git a/clients/macos/vellum-assistant/Features/Onboarding/OnboardingState.swift b/clients/macos/vellum-assistant/Features/Onboarding/OnboardingState.swift index b2e5036a9b1..16bdf8fa040 100644 --- a/clients/macos/vellum-assistant/Features/Onboarding/OnboardingState.swift +++ b/clients/macos/vellum-assistant/Features/Onboarding/OnboardingState.swift @@ -1,5 +1,10 @@ import SwiftUI +enum OnboardingVariant: String { + case `default` + case firstMeeting = "first_meeting" +} + enum ActivationKey: String, CaseIterable { case fn case ctrl @@ -26,6 +31,13 @@ final class OnboardingState { var skipPermissionChecks: Bool = false var hasHatched: Bool = false var interviewCompleted: Bool = false + var onboardingVariant: OnboardingVariant = .default + + // First-meeting-specific state + var conversationCompleted: Bool = false + var capabilitiesBriefingShown: Bool = false + var observationCompleted: Bool = false + var firstTaskCandidate: String? = nil var anyPermissionDenied: Bool { !speechGranted || !accessibilityGranted || !screenGranted @@ -59,6 +71,10 @@ final class OnboardingState { hasHatched = UserDefaults.standard.bool(forKey: "onboarding.hatched") interviewCompleted = UserDefaults.standard.bool(forKey: "onboarding.interviewCompleted") } + if let rawVariant = UserDefaults.standard.string(forKey: "onboarding.variant"), + let variant = OnboardingVariant(rawValue: rawVariant) { + onboardingVariant = variant + } } func advance() { @@ -75,10 +91,11 @@ final class OnboardingState { UserDefaults.standard.set(chosenKey.rawValue, forKey: "onboarding.key") UserDefaults.standard.set(hasHatched, forKey: "onboarding.hatched") UserDefaults.standard.set(interviewCompleted, forKey: "onboarding.interviewCompleted") + UserDefaults.standard.set(onboardingVariant.rawValue, forKey: "onboarding.variant") } static func clearPersistedState() { - for key in ["onboarding.step", "onboarding.name", "onboarding.key", "onboarding.hatched", "onboarding.interviewCompleted"] { + for key in ["onboarding.step", "onboarding.name", "onboarding.key", "onboarding.hatched", "onboarding.interviewCompleted", "onboarding.variant"] { UserDefaults.standard.removeObject(forKey: key) } } diff --git a/clients/macos/vellum-assistant/Features/Onboarding/OnboardingWindow.swift b/clients/macos/vellum-assistant/Features/Onboarding/OnboardingWindow.swift index 97ca4814b25..055c61f7cc9 100644 --- a/clients/macos/vellum-assistant/Features/Onboarding/OnboardingWindow.swift +++ b/clients/macos/vellum-assistant/Features/Onboarding/OnboardingWindow.swift @@ -17,26 +17,44 @@ final class OnboardingWindow { if CommandLine.arguments.contains("--skip-permission-checks") { state.skipPermissionChecks = true } + if let idx = CommandLine.arguments.firstIndex(of: "--onboarding-variant"), + idx + 1 < CommandLine.arguments.count, + CommandLine.arguments[idx + 1] == "first_meeting" { + state.onboardingVariant = .firstMeeting + } #endif - let flowView = OnboardingFlowView( - state: state, - daemonClient: daemonClient, - onComplete: { [weak self] in - guard let self else { return } - self.onComplete?(self.state) - }, - onOpenSettings: { [weak self] in - guard let self else { return } - self.onComplete?(self.state) - // Settings will be opened by AppDelegate after onComplete - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { - NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil) - } + let onComplete: () -> Void = { [weak self] in + guard let self else { return } + self.onComplete?(self.state) + } + let onOpenSettings: () -> Void = { [weak self] in + guard let self else { return } + self.onComplete?(self.state) + // Settings will be opened by AppDelegate after onComplete + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil) } - ) + } + + let contentView: AnyView + if state.onboardingVariant == .firstMeeting { + contentView = AnyView(FirstMeetingFlowView( + state: state, + daemonClient: daemonClient, + onComplete: onComplete, + onOpenSettings: onOpenSettings + )) + } else { + contentView = AnyView(OnboardingFlowView( + state: state, + daemonClient: daemonClient, + onComplete: onComplete, + onOpenSettings: onOpenSettings + )) + } - let hostingController = NSHostingController(rootView: flowView) + let hostingController = NSHostingController(rootView: contentView) let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 1366, height: 849),