From 9e21811bd542949741a158f85de0efdc97831a6f Mon Sep 17 00:00:00 2001 From: lk340 Date: Mon, 4 Aug 2025 13:24:35 -0400 Subject: [PATCH] Add updated logged out CTAs: * Add `AddModelAlert` view to `ContentView` that appears when the user is logged out and has no access to both remote and local models. * Update `SubscriptionAlert` view in accordance to design updates. * Update `AuthFlow` and `ModelSelectionView` with new CTA UI. * Bring back redirect CTA to `AuthFlow` now that we have proper CTAs in place for when the user is accessing Onit while logged out. * Remove old CTA logic from `AuthFlow` and `PromptCore`. --- macos/Onit/AppState.swift | 2 + .../Icons/plus-thin.imageset/Contents.json | 15 ++ .../Icons/plus-thin.imageset/plus-thin.svg | 3 + .../Icons/user.imageset/Contents.json | 15 ++ .../Icons/user.imageset/user.svg | 3 + macos/Onit/UI/Alerts/AddModelAlert.swift | 66 +++++++++ macos/Onit/UI/Alerts/SubscriptionAlert.swift | 53 ++++--- macos/Onit/UI/Auth/AuthFlow.swift | 133 ++++-------------- macos/Onit/UI/Content/ContentView.swift | 30 ++-- .../UI/Prompt/Dialogs/SetUpButtonStyle.swift | 4 +- macos/Onit/UI/Prompt/PromptCore.swift | 29 +--- .../Prompt/Selection/ModelSelectionView.swift | 117 ++++++++------- 12 files changed, 245 insertions(+), 225 deletions(-) create mode 100644 macos/Onit/Assets.xcassets/Icons/plus-thin.imageset/Contents.json create mode 100644 macos/Onit/Assets.xcassets/Icons/plus-thin.imageset/plus-thin.svg create mode 100644 macos/Onit/Assets.xcassets/Icons/user.imageset/Contents.json create mode 100644 macos/Onit/Assets.xcassets/Icons/user.imageset/user.svg create mode 100644 macos/Onit/UI/Alerts/AddModelAlert.swift diff --git a/macos/Onit/AppState.swift b/macos/Onit/AppState.swift index 7a2b8fda9..9cc526520 100644 --- a/macos/Onit/AppState.swift +++ b/macos/Onit/AppState.swift @@ -63,6 +63,8 @@ class AppState: NSObject, SPUUpdaterDelegate { var showProLimitAlert: Bool = false var subscriptionPlanError: String = "" + var showAddModelAlert: Bool = false + private var authCancellable: AnyCancellable? = nil // MARK: - Initializer diff --git a/macos/Onit/Assets.xcassets/Icons/plus-thin.imageset/Contents.json b/macos/Onit/Assets.xcassets/Icons/plus-thin.imageset/Contents.json new file mode 100644 index 000000000..0fbd52799 --- /dev/null +++ b/macos/Onit/Assets.xcassets/Icons/plus-thin.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "plus-thin.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/macos/Onit/Assets.xcassets/Icons/plus-thin.imageset/plus-thin.svg b/macos/Onit/Assets.xcassets/Icons/plus-thin.imageset/plus-thin.svg new file mode 100644 index 000000000..a04d6a310 --- /dev/null +++ b/macos/Onit/Assets.xcassets/Icons/plus-thin.imageset/plus-thin.svg @@ -0,0 +1,3 @@ + + + diff --git a/macos/Onit/Assets.xcassets/Icons/user.imageset/Contents.json b/macos/Onit/Assets.xcassets/Icons/user.imageset/Contents.json new file mode 100644 index 000000000..abd9b2f32 --- /dev/null +++ b/macos/Onit/Assets.xcassets/Icons/user.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "user.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/macos/Onit/Assets.xcassets/Icons/user.imageset/user.svg b/macos/Onit/Assets.xcassets/Icons/user.imageset/user.svg new file mode 100644 index 000000000..0ba5c3085 --- /dev/null +++ b/macos/Onit/Assets.xcassets/Icons/user.imageset/user.svg @@ -0,0 +1,3 @@ + + + diff --git a/macos/Onit/UI/Alerts/AddModelAlert.swift b/macos/Onit/UI/Alerts/AddModelAlert.swift new file mode 100644 index 000000000..9dd438b8b --- /dev/null +++ b/macos/Onit/UI/Alerts/AddModelAlert.swift @@ -0,0 +1,66 @@ +// +// AddModelAlert.swift +// Onit +// +// Created by Loyd Kim on 8/4/25. +// + +import Defaults +import SwiftUI + +struct AddModelAlert: View { + @Environment(\.appState) var appState + @Environment(\.openSettings) var openSettings + + var body: some View { + SubscriptionAlert( + title: "Add a model to continue", + description: "Connect at least one remote or local model in Settings to chat with Onit.", + spacingBetweenSections: 10, + showApiCta: false, + footerSupportingText: "Or, sign up for free access to 30+ models from OpenAI, Anthropic and more!" + ) { + HStack(alignment: .center, spacing: 8) { + ctaButton( + text: "Add in Settings", + background: .gray500, + hoverBackground: .gray400 + ) { + appState.settingsTab = .models + openSettings() + } + + ctaButton( + text: "Sign up", + background: .blue400, + hoverBackground: .blue350 + ) { + Defaults[.authFlowStatus] = .showSignUp + } + } + .padding(.top, 4) + } + } + + // MARK: Child Components + + private func ctaButton( + text: String, + background: Color, + hoverBackground: Color, + action: @escaping () -> Void + ) -> some View { + TextButton( + fillContainer: false, + cornerRadius: 6, + background: background, + hoverBackground: hoverBackground + ) { + Text(text) + .frame(maxWidth: .infinity) + .styleText(size: 13, weight: .regular, align: .center) + } action: { + action() + } + } +} diff --git a/macos/Onit/UI/Alerts/SubscriptionAlert.swift b/macos/Onit/UI/Alerts/SubscriptionAlert.swift index b22c08393..7586e05bd 100644 --- a/macos/Onit/UI/Alerts/SubscriptionAlert.swift +++ b/macos/Onit/UI/Alerts/SubscriptionAlert.swift @@ -7,7 +7,7 @@ import SwiftUI -struct SubscriptionAlert: View { +struct SubscriptionAlert: View { @Environment(\.appState) var appState @Environment(\.openSettings) var openSettings @@ -18,11 +18,14 @@ struct SubscriptionAlert: View { private let descriptionAction: (() -> Void)? private let descriptionActionLoading: Bool private let caption: String? + private let spacingBetweenSections: CGFloat + private let showApiCta: Bool private let subscriptionText: String? private let subscriptionAction: (() -> Void)? private let showSubscriptionPerks: Bool private let footerSupportingText: String? private let errorMessage: Binding? + @ViewBuilder private let child: () -> Child init( title: String, @@ -32,11 +35,14 @@ struct SubscriptionAlert: View { descriptionAction: (() -> Void)? = nil, descriptionActionLoading: Bool = false, caption: String? = nil, + spacingBetweenSections: CGFloat = 16, + showApiCta: Bool = true, subscriptionText: String? = nil, subscriptionAction: (() -> Void)? = nil, showSubscriptionPerks: Bool = false, footerSupportingText: String? = nil, - errorMessage: Binding? = nil + errorMessage: Binding? = nil, + @ViewBuilder child: @escaping () -> Child = { EmptyView() } ) { self.title = title self.close = close @@ -45,16 +51,19 @@ struct SubscriptionAlert: View { self.descriptionAction = descriptionAction self.descriptionActionLoading = descriptionActionLoading self.caption = caption + self.spacingBetweenSections = spacingBetweenSections + self.showApiCta = showApiCta self.subscriptionText = subscriptionText self.subscriptionAction = subscriptionAction self.showSubscriptionPerks = showSubscriptionPerks self.footerSupportingText = footerSupportingText self.errorMessage = errorMessage + self.child = child } var body: some View { HStack(alignment: .center) { - VStack(alignment: .center, spacing: 16) { + VStack(alignment: .center, spacing: spacingBetweenSections) { if let errorMessage = errorMessage, !errorMessage.wrappedValue.isEmpty { @@ -80,7 +89,7 @@ struct SubscriptionAlert: View { } if let caption = caption { - Text(caption).styleText(size: 13, weight: .regular) + captionText(caption) } } @@ -93,6 +102,8 @@ struct SubscriptionAlert: View { } footer + + child() } .padding(16) .background(.gray900) @@ -138,13 +149,7 @@ extension SubscriptionAlert { } private var descriptionText: some View { - Text(description) - .styleText( - size: 13, - weight: .regular, - color: .gray100, - align: .center - ) + captionText(description) } private func descriptionButton(_ action: @escaping () -> Void) -> some View { @@ -179,28 +184,30 @@ extension SubscriptionAlert { ) } - private func footerTextView(_ text: String) -> some View { - Text(text).styleText(size: 11, color: .gray200) + private func captionText(_ text: String) -> some View { + Text(text).styleText(size: 13, weight: .regular, color: .gray100, align: .center) } private var footer: some View { - VStack(alignment: .center, spacing: 16) { + VStack(alignment: .center, spacing: spacingBetweenSections) { footerDivider VStack(alignment: .center, spacing: 4) { if let footerSupportingText = footerSupportingText { - footerTextView(footerSupportingText) + captionText(footerSupportingText) } - HStack(spacing: 4) { - footerTextView("Or, add your API key in") - - Button { - openModelSettings() - } label: { - Text("Settings").styleText(size: 11, color: .gray100) + if showApiCta { + HStack(spacing: 4) { + captionText("Or, add your API key in") + + Button { + openModelSettings() + } label: { + Text("Settings").styleText(size: 13, weight: .regular, color: .primary, align: .center) + } + .buttonStyle(PlainButtonStyle()) } - .buttonStyle(PlainButtonStyle()) } } } diff --git a/macos/Onit/UI/Auth/AuthFlow.swift b/macos/Onit/UI/Auth/AuthFlow.swift index 4513597f6..d6bd6ba3d 100644 --- a/macos/Onit/UI/Auth/AuthFlow.swift +++ b/macos/Onit/UI/Auth/AuthFlow.swift @@ -63,8 +63,7 @@ struct AuthFlow: View { emailAuthTokenForm } else { form - notLoggedInCTA - redirectSection + redirectCTA Spacer() closeButton } @@ -128,9 +127,15 @@ extension AuthFlow { VStack(alignment: .center, spacing: 0) { Image("Logo").padding(.bottom, 19) - Text(isSignUp ? "Create your account" : "Sign in to Onit") - .styleText(size: 23) - .padding(.bottom, 28) + VStack(alignment: .center, spacing: 8) { + Text(isSignUp ? "Create your account" : "Sign in to Onit") + .styleText(size: 23, align: .center) + + Text("Sign up to access 30+ models from OpenAI, Anthropic & more!") + .styleText(size: 15, color: .gray100, align: .center) + } + .padding(.bottom, 24) + .frame(width: 291) VStack(alignment: .center, spacing: 12) { formAuthButtons @@ -154,102 +159,10 @@ extension AuthFlow { .styleText(size: 12, align: .center) } - private struct LocalModelsCTA: View { - @Environment(\.appState) var appState - @Environment(\.openSettings) var openSettings - - @State private var isHoveredFetchButton: Bool = false - @State private var isHoveredModelAddButton: Bool = false - @State private var isFetchingLocalModels: Bool = false - @State private var localModelFetchErrorMessage: String = "" - - var body: some View { - VStack(alignment: .center, spacing: 4) { - Text("No local models found.") - .styleText( - size: 13, - weight: .regular, - color: .gray100 - ) - - Button { - localModelFetchErrorMessage = "" - appState.localFetchFailed = false - isFetchingLocalModels = true - - Task { - await appState.fetchLocalModels() - - if appState.localFetchFailed { - localModelFetchErrorMessage = "Couldn't find local models." - } - - isFetchingLocalModels = false - } - } label: { - if isFetchingLocalModels { - Loader() - } else { - Text("Search for local models...") - .styleText( - size: 13, - weight: .regular, - underline: isHoveredFetchButton - ) - .onHover { isHovering in isHoveredFetchButton = isHovering} - } - } - - if !localModelFetchErrorMessage.isEmpty { - VStack(alignment: .center, spacing: 4) { - Text(localModelFetchErrorMessage) - .styleText( - size: 13, - weight: .regular, - color: .red - ) - - Button { - appState.settingsTab = .models - openSettings() - } label: { - Text("Manually add models →") - .styleText( - size: 13, - weight: .regular, - underline: isHoveredModelAddButton - ) - .onHover { isHovering in isHoveredModelAddButton = isHovering} - } - } - .padding(.top, 8) - } - } - } - } - - @ViewBuilder - private var notLoggedInCTA: some View { - if !authManager.userLoggedIn { - if !hasLocalModels { - LocalModelsCTA() - } else { - Text("\(isSignUp ? "Sign Up" : "Sign in") to access the latest models from OpenAI, Anthropic, and more!") - .padding(.horizontal, 40) - .styleText( - size: 13, - weight: .regular, - color: .gray100, - align: .center - ) - } - } - } - private var redirectSection: some View { HStack(spacing: 6) { Text(isSignUp ? "Already have an account?" : "Don't have an account?") - .styleText(size: 13, weight: .regular, color: .gray100) + .styleText(size: 13, weight: .regular, color: .gray100, align: .center) Button { if isSignUp { @@ -268,19 +181,21 @@ extension AuthFlow { } } - private var signUpRedirect: some View { - VStack(alignment: .center, spacing: 12) { + private var redirectCTA: some View { + VStack(alignment: .center, spacing: 10) { redirectSection - Button { - authFlowStatus = .hideAuth - } label: { - Text(generateSkipText()) - .styleText(size: 13, weight: .regular, underline: isHoveredSkipButton) - } - .buttonStyle(PlainButtonStyle()) - .onHover { isHovering in - isHoveredSkipButton = isHovering + if authFlowStatus == .showSignUp { + Button { + authFlowStatus = .hideAuth + } label: { + Text(generateSkipText()) + .styleText(size: 13, weight: .regular, align: .center, underline: isHoveredSkipButton) + } + .buttonStyle(PlainButtonStyle()) + .onHover { isHovering in + isHoveredSkipButton = isHovering + } } } } diff --git a/macos/Onit/UI/Content/ContentView.swift b/macos/Onit/UI/Content/ContentView.swift index f34eee55a..ad1795d2d 100644 --- a/macos/Onit/UI/Content/ContentView.swift +++ b/macos/Onit/UI/Content/ContentView.swift @@ -107,6 +107,12 @@ struct ContentView: View { if state?.showChatView == true { ZStack { + alertView( + isPresented: appState.showAddModelAlert, + id: "add_model_alert", + content: AddModelAlert() + ) + alertView( isPresented: showTwoWeekProTrialEndedAlert, id: "trial_ended_alert", @@ -195,12 +201,14 @@ struct ContentView: View { .onChange(of: authManager.userLoggedIn) { _, _ in setModeAndValidRemoteModelWithAuth() } - .onChange(of: availableLocalModels) {_, localModelsList in + .onChange(of: canAccessRemoteModels) {_, _ in if !authManager.userLoggedIn { - let canAccessLocalModels = !localModelsList.isEmpty - let cannotAccessModels = !canAccessRemoteModels && !canAccessLocalModels - - authFlowStatus = cannotAccessModels ? .showSignUp : .hideAuth + handleShowAddModelAlert() + } + } + .onChange(of: availableLocalModels) {_, _ in + if !authManager.userLoggedIn { + handleShowAddModelAlert() } } .onChange(of: modelProvidersManager.numberRemoteProvidersInUse) { _, _ in @@ -211,16 +219,20 @@ struct ContentView: View { } } + private func handleShowAddModelAlert() { + let canAccessLocalModels = !availableLocalModels.isEmpty + let cannotAccessModels = !modelProvidersManager.userHasRemoteAPITokens && !canAccessLocalModels + appState.showAddModelAlert = cannotAccessModels + } + private func setModeAndValidRemoteModelWithAuth() { appState.setModeAndValidRemoteModel() if authManager.userLoggedIn { authFlowStatus = .hideAuth + appState.showAddModelAlert = false } else { - let canAccessLocalModels = !availableLocalModels.isEmpty - let cannotAccessModels = !canAccessRemoteModels && !canAccessLocalModels - - authFlowStatus = cannotAccessModels ? .showSignUp : .hideAuth + handleShowAddModelAlert() } } diff --git a/macos/Onit/UI/Prompt/Dialogs/SetUpButtonStyle.swift b/macos/Onit/UI/Prompt/Dialogs/SetUpButtonStyle.swift index 68b0993a1..ba4e2c99e 100644 --- a/macos/Onit/UI/Prompt/Dialogs/SetUpButtonStyle.swift +++ b/macos/Onit/UI/Prompt/Dialogs/SetUpButtonStyle.swift @@ -29,7 +29,7 @@ struct SetUpButtonStyle: ButtonStyle { .padding(8) .foregroundStyle(foregroundColor) - .background(backgroundColor.opacity(hovering ? 0.9 : 1), in: .rect(cornerRadius: 8)) + .background(backgroundColor.opacity(hovering ? 0.9 : 1), in: .rect(cornerRadius: 6)) .opacity(configuration.isPressed ? 0.9 : 1) .animation(.spring(duration: 1 / 3), value: hovering) .fontWeight(.semibold) @@ -56,7 +56,7 @@ struct SetUpButtonStyle: ButtonStyle { case .primary: return .blue400 case .default: - return .gray700 + return .gray500 } } } diff --git a/macos/Onit/UI/Prompt/PromptCore.swift b/macos/Onit/UI/Prompt/PromptCore.swift index d3cb146eb..6fe725678 100644 --- a/macos/Onit/UI/Prompt/PromptCore.swift +++ b/macos/Onit/UI/Prompt/PromptCore.swift @@ -50,8 +50,6 @@ struct PromptCore: View { @State private var disableSend: Bool = false - @State private var isHoveredSignInButton: Bool = false - @FocusState private var isFocused: Bool private var unfocusedBorder = GradientBorder( @@ -79,10 +77,6 @@ struct PromptCore: View { InputView(input: pendingInput) } - if !authManager.userLoggedIn { - signInButton - } - VStack(spacing: 6) { contextAndInput PromptCoreFooter( @@ -143,27 +137,6 @@ struct PromptCore: View { // MARK: - Child Components extension PromptCore { - private var signInButton: some View { - Button { - GeneralTabAccount.openSignInAuth() - } label: { - Text("Sign In →") - .styleText( - size: 13, - weight: .regular, - color: isHoveredSignInButton ? Color.primary : .gray100, - underline: isHoveredSignInButton - ) - .addAnimation(dependency: isHoveredSignInButton) - .onHover { isHovering in - isHoveredSignInButton = isHovering - } - } - .padding(.top, 6) - .padding(.horizontal, 16) - .frame(maxWidth: .infinity, alignment: .leading) - } - @ViewBuilder private var textField: some View { if let windowState = windowState { @@ -283,7 +256,7 @@ extension PromptCore { } private var showingAlert: Bool { - showTwoWeekProTrialEndedAlert || appState.showFreeLimitAlert || appState.showProLimitAlert + appState.showAddModelAlert || showTwoWeekProTrialEndedAlert || appState.showFreeLimitAlert || appState.showProLimitAlert } private var isRemote: Bool { diff --git a/macos/Onit/UI/Prompt/Selection/ModelSelectionView.swift b/macos/Onit/UI/Prompt/Selection/ModelSelectionView.swift index c4cec07ac..236a41e77 100644 --- a/macos/Onit/UI/Prompt/Selection/ModelSelectionView.swift +++ b/macos/Onit/UI/Prompt/Selection/ModelSelectionView.swift @@ -101,28 +101,38 @@ struct ModelSelectionView: View { ) } - private func arrowButton(_ text: String, action: @escaping () -> Void) -> some View { - Button(text) { - action() - } - .buttonStyle(SetUpButtonStyle(showArrow: true)) - .frame(maxWidth: .infinity, alignment: .leading) - } - @ViewBuilder private var signInCTA: some View { if !authManager.userLoggedIn { VStack(alignment: .leading, spacing: 8) { emptyText("Sign in to gain access to models from OpenAI, Anthropic, and more!") - arrowButton("Sign In") { + Button("Sign In") { GeneralTabAccount.openSignInAuth() } + .buttonStyle(SetUpButtonStyle(showArrow: true)) + .frame(maxWidth: .infinity, alignment: .leading) } .padding(.horizontal, 16) .padding(.bottom, 8) } } + + private func addModelCTAButton(isLocal: Bool = false) -> some View { + TextButton( + iconSize: 16, + icon: .plusThin, + text: "Add manually" + ) { + if isLocal { + AnalyticsManager.ModelPicker.localSetupPressed() + } + + appState.settingsTab = .models + openSettings() + open.wrappedValue = false + } + } var remote: some View { return MenuSection( @@ -135,30 +145,38 @@ struct ModelSelectionView: View { contentBottomPadding: 0, contentLeftPadding: 0 ) { - if filteredRemoteModels.isEmpty { - VStack(alignment: .leading, spacing: 8) { - emptyText("No remote models.") - - arrowButton("Setup remote models") { - appState.settingsTab = .models - openSettings() - } - } - .padding([.horizontal, .bottom], 16) - } else { - remoteModelsView - } + remoteModelsView } } var remoteModelsView: some View { VStack(spacing: 0) { - ForEach(filteredRemoteModels) { remoteModel in - RemoteModelButton( - modelSelectionViewOpen: open, - selectedModel: selectedModel, - remoteModel: remoteModel - ) + if filteredRemoteModels.isEmpty { + if !authManager.userLoggedIn { + TextButton(fillContainer: false) { + HStack(alignment: .center, spacing: 10) { + Image(.user) + .padding(.leading, -1) + + Text("Sign up for access") + .styleText(size: 14, color: .primary) + } + .frame(maxWidth: .infinity, alignment: .leading) + } action: { + Defaults[.authFlowStatus] = .showSignUp + open.wrappedValue = false + } + } + + addModelCTAButton() + } else { + ForEach(filteredRemoteModels) { remoteModel in + RemoteModelButton( + modelSelectionViewOpen: open, + selectedModel: selectedModel, + remoteModel: remoteModel + ) + } } } .padding(.horizontal, 8) @@ -176,37 +194,28 @@ struct ModelSelectionView: View { contentBottomPadding: 0, contentLeftPadding: 0 ) { - if availableLocalModels.isEmpty { - VStack(alignment: .leading, spacing: 8) { - emptyText("No local models.") - - arrowButton("Setup local models") { - AnalyticsManager.ModelPicker.localSetupPressed() - appState.settingsTab = .models - openSettings() - } - } - .padding([.horizontal, .bottom], 16) - } else { - localModelsView - } + localModelsView } } var localModelsView: some View { VStack(spacing: 0) { - ForEach(filteredLocalModels, id: \.self) { localModelName in - TextButton( - iconSize: 16, - selected: isSelectedLocalModel(modelName: localModelName), - icon: localModelName.lowercased().contains("llama") ? .logoOllama : .logoProviderUnknown, - text: localModelName, - action: { - AnalyticsManager.ModelPicker.modelSelected(local: true, model: localModelName) - selectedModel.wrappedValue = .local(localModelName) - open.wrappedValue = false - } - ) + if availableLocalModels.isEmpty { + addModelCTAButton(isLocal: true) + } else { + ForEach(filteredLocalModels, id: \.self) { localModelName in + TextButton( + iconSize: 16, + selected: isSelectedLocalModel(modelName: localModelName), + icon: localModelName.lowercased().contains("llama") ? .logoOllama : .logoProviderUnknown, + text: localModelName, + action: { + AnalyticsManager.ModelPicker.modelSelected(local: true, model: localModelName) + selectedModel.wrappedValue = .local(localModelName) + open.wrappedValue = false + } + ) + } } } .padding(.horizontal, 8)