Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

Contributions are welcome — see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.

## [1.7.5-build-109] - 2026-09-14
## [1.7.5-build-110] - 2026-09-15

### Added

- Markdown footnotes with numbered references and multiline notes collected at the end of assistant responses
- Native subscript and superscript rendering for `<sub>` and `<sup>` tags across Markdown text, including formatted links
- iPad sidebar conversation search with results in the main area, empty-query guidance, and direct chat navigation that dismisses search focus while preserving the query

### Changed

Expand Down
1 change: 1 addition & 0 deletions TestFlight/WhatToTest.en-US.txt
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ Hi there! We've got some great new features for you in this update.
• Your conversations just got easier to read. We've brought your messages and the model's replies closer together, with each reply's time, details, and actions in a compact row. Less empty space, more conversation in view.
• Better formatting for your replies! Markdown tables now display correctly even without separators at their outer edges. Footnotes appear as numbered notes at the end of the message, and escaped characters are handled correctly inside tables, too.
• Small details make a difference, too. You can now see subscripts and superscripts, like H₂O or x², even inside links and alongside bold or italic text. Their size follows your text size on iPhone, iPad, and Mac.
• Finding a chat on iPad is now more convenient. Search directly from the top of the sidebar and see matching conversations on the right as you type. Open a result to jump into that chat and dismiss the keyboard; your search stays there for later. Prefer the top bar? The search button is still available there, too.
• Voice setup is a little easier as well. If you haven't selected a model, or your previous choice is no longer available, text-to-speech picks the first available model and speech-to-text selects Apple speech recognition. Both work even when there's only one option. We've also included smaller fixes and improvements to keep things running smoothly.

Thanks for your continued support and for helping us build the best possible LLM client together.
Expand Down
1 change: 1 addition & 0 deletions TestFlight/WhatToTest.es-ES.txt
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
• Tus conversaciones, más cómodas de leer. Hemos acercado tus mensajes y las respuestas del modelo, y reunido la hora, los detalles y las acciones de cada respuesta en una fila compacta. Menos espacio vacío y más conversación a la vista.
• ¡Mejor formato para las respuestas! Las tablas de Markdown ahora se muestran bien aunque no incluyan separadores en los extremos. Las notas al pie aparecen numeradas al final del mensaje y los caracteres escapados se respetan también dentro de las tablas.
• Los pequeños detalles también cuentan. Ya puedes ver subíndices y superíndices, como H₂O o x², incluso en enlaces y junto a negrita o cursiva. Su tamaño se adapta al del texto en iPhone, iPad y Mac.
• Encontrar un chat en iPad es ahora más cómodo. Busca directamente desde la parte superior del menú lateral y verás las conversaciones coincidentes a la derecha mientras escribes. Abre un resultado para entrar en ese chat y cerrar el teclado; la búsqueda se conserva para retomarla después. ¿Prefieres la barra superior? La lupa sigue disponible también ahí.
• La configuración de voz también es ahora más sencilla. Si no has seleccionado ningún modelo o el que usabas ya no está disponible, la conversión de texto a voz seleccionará el primer modelo disponible y la conversión de voz a texto utilizará el reconocimiento de voz de Apple. Ambas funciones también funcionan cuando solo hay una opción disponible. Además, hemos incluido pequeñas correcciones y mejoras para que todo siga funcionando como debe.

Gracias por seguir apoyándonos y por ayudarnos a crear juntos el mejor cliente posible para modelos de lenguaje.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,80 @@ struct SearchConversationsView: View {
@Environment(\.scenePhase) private var scenePhase

@State private var viewModel = ConversationListViewModel()
@State private var searchText = ""
@State private var localSearchText = ""
@State private var selectedConversation: Conversation?

private let externalSearchText: Binding<String>?
private let showsSearchField: Bool
private let onConversationSelected: ((Conversation) -> Void)?

// MARK: - Init

init(
searchText: Binding<String>? = nil,
showsSearchField: Bool = true,
onConversationSelected: ((Conversation) -> Void)? = nil
) {
externalSearchText = searchText
self.showsSearchField = showsSearchField
self.onConversationSelected = onConversationSelected
}

// MARK: - View

var body: some View {
searchPresentation
.onChange(of: searchText) { _, newValue in
viewModel.send(.searchChanged(newValue))
}
.onChange(of: isLoaded) { _, loaded in
if loaded {
viewModel.send(.searchChanged(searchText))
}
}
.onChange(of: scenePhase) { _, newPhase in
if newPhase == .active {
viewModel.refresh()
}
}
.task {
viewModel.send(.viewAppeared)
}
}
}

// MARK: - Private

private extension SearchConversationsView {
var searchText: String { searchBinding.wrappedValue }

var searchBinding: Binding<String> { externalSearchText ?? $localSearchText }

var isLoaded: Bool {
if case .loaded = viewModel.state { return true }
return false
}

@ViewBuilder
var searchPresentation: some View {
if showsSearchField {
#if os(iOS)
searchNavigation
.searchable(
text: searchBinding,
placement: .navigationBarDrawer(displayMode: .always),
prompt: String(localized: "Search conversations...")
)
#else
searchNavigation
.searchable(text: searchBinding, prompt: String(localized: "Search conversations..."))
#endif
} else {
searchNavigation
}
}

var searchNavigation: some View {
NavigationStack {
Group {
switch viewModel.state {
Expand All @@ -38,38 +106,17 @@ struct SearchConversationsView: View {
}
}
}
#if os(iOS)
.searchable(
text: $searchText,
placement: .navigationBarDrawer(displayMode: .always),
prompt: String(localized: "Search conversations...")
)
#else
.searchable(
text: $searchText,
prompt: String(localized: "Search conversations...")
)
#endif
.onChange(of: searchText) { _, newValue in
viewModel.send(.searchChanged(newValue))
}
.onChange(of: scenePhase) { _, newPhase in
if newPhase == .active {
viewModel.refresh()
}
}
.task {
viewModel.send(.viewAppeared)
}
}
}

// MARK: - Private

private extension SearchConversationsView {
@ViewBuilder
func searchContent(_ loadedState: ConversationListViewModel.LoadedState) -> some View {
if !searchText.isEmpty && loadedState.filteredConversations.isEmpty {
if externalSearchText != nil && searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
ContentUnavailableView {
Label(String(localized: "Search Conversations"), systemImage: "magnifyingglass")
} description: {
Text(String(localized: "Search your conversations by title or message content"))
}
} else if !searchText.isEmpty && loadedState.filteredConversations.isEmpty {
ContentUnavailableView.search(text: searchText)
} else {
let conversations = searchText.isEmpty
Expand Down Expand Up @@ -99,7 +146,11 @@ private extension SearchConversationsView {
List {
ForEach(conversations) { conversation in
Button {
selectedConversation = conversation
if let onConversationSelected {
onConversationSelected(conversation)
} else {
selectedConversation = conversation
}
} label: {
conversationRow(conversation)
}
Expand Down Expand Up @@ -188,6 +239,10 @@ private extension SearchConversationsView {
SearchConversationsView()
}

#Preview("Sidebar search without a query") {
SearchConversationsView(searchText: .constant(""), showsSearchField: false)
}

#Preview("Search result with large text") {
SearchConversationsView()
.conversationRow(Conversation(
Expand Down
105 changes: 105 additions & 0 deletions openclient-llm/Shared/Features/Home/Views/HomeView+iPadSearch.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
//
// HomeView+iPadSearch.swift
// openclient-llm
//
// Created by Arturo Carretero Calvo on 14/09/2026.
// Copyright © 2026 Arturo Carretero Calvo. All rights reserved.
//

import SwiftUI

#if os(iOS)
extension HomeView {
enum AppTab: Hashable {
case chats
case models
case settings
case search
}

var tabSelection: Binding<AppTab> {
Binding(
get: { selectedTab },
set: { tab in
isSidebarSearchFocused = false
isSidebarSearchActive = false
selectedTab = tab
}
)
}

@ViewBuilder
var searchTab: some View {
if UIDevice.current.userInterfaceIdiom == .pad {
SearchConversationsView(
searchText: $iPadSearchText,
onConversationSelected: openSearchResult
)
} else {
SearchConversationsView()
}
}

var iPadSidebarSearch: some View {
HStack(spacing: 8) {
Image(systemName: "magnifyingglass")
.foregroundStyle(.secondary)
.accessibilityHidden(true)
TextField(String(localized: "Search conversations..."), text: $iPadSearchText)
.textFieldStyle(.plain)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.submitLabel(.search)
.focused($isSidebarSearchFocused)
.accessibilityIdentifier("iPadSidebarSearch")
.onSubmit { isSidebarSearchFocused = false }
if !iPadSearchText.isEmpty {
Button {
iPadSearchText = ""
activateSidebarSearch()
isSidebarSearchFocused = true
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(.secondary)
.frame(width: 44, height: 44)
}
.buttonStyle(.plain)
.accessibilityLabel(String(localized: "Clear Search"))
}
}
.padding(.leading, 12)
.padding(.trailing, iPadSearchText.isEmpty ? 12 : 0)
.frame(minHeight: 44)
.background(.quaternary, in: .capsule)
.padding(.vertical, 8)
.onChange(of: isSidebarSearchFocused) { _, focused in
if focused { activateSidebarSearch() }
}
.onAppear {
isSidebarSearchVisible = true
if selectedTab == .search { activateSidebarSearch() }
}
.onDisappear {
isSidebarSearchVisible = false
isSidebarSearchFocused = false
if isSidebarSearchActive {
isSidebarSearchActive = false
selectedTab = .search
}
}
}

func activateSidebarSearch() {
selectedTab = .chats
isSidebarSearchActive = true
}

func openSearchResult(_ conversation: Conversation) {
isSidebarSearchFocused = false
isSidebarSearchActive = false
isPrivateChatActive = false
selectedConversation = conversation
selectedTab = .chats
}
}
#endif
Loading