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)