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
Original file line number Diff line number Diff line change
Expand Up @@ -16,20 +16,25 @@ struct SearchConversationsView: View {
@State private var viewModel = ConversationListViewModel()
@State private var localSearchText = ""
@State private var selectedConversation: Conversation?
@State private var isSearchPresented = false
@FocusState private var isSearchFocused: Bool

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

// MARK: - Init

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

Expand Down Expand Up @@ -72,12 +77,32 @@ private extension SearchConversationsView {
var searchPresentation: some View {
if showsSearchField {
#if os(iOS)
searchNavigation
.searchable(
text: searchBinding,
placement: .navigationBarDrawer(displayMode: .always),
prompt: String(localized: "Search conversations...")
)
if externalSearchText != nil {
searchNavigation
.searchFocused($isSearchFocused)
.searchable(
text: searchBinding,
isPresented: $isSearchPresented,
placement: .navigationBarDrawer(displayMode: .always),
prompt: String(localized: "Search") + "..."
)
.onChange(of: isSearchActive, initial: true) { _, active in
if active {
isSearchPresented = true
}
isSearchFocused = active
}
.onDisappear {
isSearchFocused = false
}
} else {
searchNavigation
.searchable(
text: searchBinding,
placement: .navigationBarDrawer(displayMode: .always),
prompt: String(localized: "Search conversations...")
)
}
#else
searchNavigation
.searchable(text: searchBinding, prompt: String(localized: "Search conversations..."))
Expand Down Expand Up @@ -147,6 +172,7 @@ private extension SearchConversationsView {
ForEach(conversations) { conversation in
Button {
if let onConversationSelected {
isSearchFocused = false
onConversationSelected(conversation)
} else {
selectedConversation = conversation
Expand Down
74 changes: 32 additions & 42 deletions openclient-llm/Shared/Features/Home/Views/HomeView+iPadSearch.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ extension HomeView {
Binding(
get: { selectedTab },
set: { tab in
// Switching to results can write the Chats selection back before its view appears.
guard !isSidebarSearchFocusRequested || tab != .chats else { return }
isSidebarSearchFocusRequested = false
isSidebarSearchFocused = false
isSidebarSearchActive = false
selectedTab = tab
Expand All @@ -33,6 +36,7 @@ extension HomeView {
if UIDevice.current.userInterfaceIdiom == .pad {
SearchConversationsView(
searchText: $iPadSearchText,
isSearchActive: selectedTab == .search && !isSidebarSearchVisible,
onConversationSelected: openSearchResult
)
} else {
Expand All @@ -41,46 +45,36 @@ extension HomeView {
}

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)
SidebarSearchField(
text: $iPadSearchText,
isFocused: $isSidebarSearchFocused,
focusRequested: $isSidebarSearchFocusRequested,
onActivated: activateSidebarSearch
)
.padding(.horizontal, -16)
.padding(.vertical, 8)
.onChange(of: isSidebarSearchFocused) { _, focused in
if focused { activateSidebarSearch() }
}

func activateSidebarSearch() {
guard !isSidebarSearchActive else {
isSidebarSearchFocused = true
return
}
.onAppear {
isSidebarSearchVisible = true
isSidebarSearchFocusRequested = true
selectedTab = .chats
isSidebarSearchActive = true
isSidebarSearchFocused = true
}

func updateSidebarPlacement(_ placement: TabBarPlacement) {
guard UIDevice.current.userInterfaceIdiom == .pad else { return }
let showsSidebar = placement == .sidebar
guard showsSidebar != isSidebarSearchVisible else { return }
isSidebarSearchVisible = showsSidebar
if showsSidebar {
if selectedTab == .search { activateSidebarSearch() }
}
.onDisappear {
isSidebarSearchVisible = false
} else {
isSidebarSearchFocusRequested = false
isSidebarSearchFocused = false
if isSidebarSearchActive {
isSidebarSearchActive = false
Expand All @@ -89,12 +83,8 @@ extension HomeView {
}
}

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

func openSearchResult(_ conversation: Conversation) {
isSidebarSearchFocusRequested = false
isSidebarSearchFocused = false
isSidebarSearchActive = false
isPrivateChatActive = false
Expand Down
19 changes: 17 additions & 2 deletions openclient-llm/Shared/Features/Home/Views/HomeView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@ struct HomeView: View {
@State var iPadSearchText = ""
@State var isSidebarSearchVisible = false
@State var isSidebarSearchActive = false
@FocusState var isSidebarSearchFocused: Bool
@State var isSidebarSearchFocusRequested = false
@State var isSidebarSearchFocused = false
#endif

// MARK: - Init
Expand Down Expand Up @@ -133,6 +134,7 @@ private extension HomeView {
TabView(selection: tabSelection) {
Tab(value: AppTab.chats) {
chatsTab
.modifier(TabBarPlacementObserver(onChange: updateSidebarPlacement))
} label: {
Label {
Text(String(localized: "Chats"))
Expand All @@ -143,6 +145,7 @@ private extension HomeView {
}
Tab(value: AppTab.models) {
ModelsView()
.modifier(TabBarPlacementObserver(onChange: updateSidebarPlacement))
} label: {
Label {
Text(String(localized: "Models"))
Expand All @@ -153,6 +156,7 @@ private extension HomeView {
}
Tab(value: AppTab.settings) {
SettingsView(requestedPresentation: $requestedSettingsPresentation)
.modifier(TabBarPlacementObserver(onChange: updateSidebarPlacement))
} label: {
Label {
Text(String(localized: "Settings"))
Expand All @@ -163,6 +167,7 @@ private extension HomeView {
}
Tab(value: AppTab.search, role: .search) {
searchTab
.modifier(TabBarPlacementObserver(onChange: updateSidebarPlacement))
} label: {
Label(String(localized: "Search"), systemImage: "magnifyingglass")
}
Expand All @@ -176,18 +181,21 @@ private extension HomeView {
}
.onChange(of: selectedTab) { _, tab in
if tab != .chats {
isSidebarSearchFocusRequested = false
isSidebarSearchActive = false
isSidebarSearchFocused = false
}
}
.onChange(of: selectedConversation) { _, conversation in
if conversation != nil {
isSidebarSearchFocusRequested = false
isSidebarSearchActive = false
isSidebarSearchFocused = false
}
}
.onChange(of: isPrivateChatActive) { _, active in
if active {
isSidebarSearchFocusRequested = false
isSidebarSearchActive = false
isSidebarSearchFocused = false
}
Expand All @@ -202,6 +210,14 @@ private extension HomeView {
showsSearchField: false,
onConversationSelected: openSearchResult
)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.contentShape(Rectangle())
.simultaneousGesture(
TapGesture().onEnded {
isSidebarSearchFocusRequested = false
isSidebarSearchFocused = false
}
)
} else {
iPhoneChatsLayout
}
Expand Down Expand Up @@ -244,7 +260,6 @@ private extension HomeView {
#if os(iOS)
if isSidebarSearchVisible {
activateSidebarSearch()
isSidebarSearchFocused = true
} else {
selectedTab = .search
}
Expand Down
88 changes: 88 additions & 0 deletions openclient-llm/Shared/Features/Home/Views/SidebarSearchField.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
//
// SidebarSearchField.swift
// openclient-llm
//
// Created by Arturo Carretero Calvo on 15/09/2026.
// Copyright © 2026 Arturo Carretero Calvo. All rights reserved.
//

import SwiftUI

#if os(iOS)
struct SidebarSearchField: View {
// MARK: - Properties

@Binding var text: String
@Binding var isFocused: Bool
@Binding var focusRequested: Bool
let onActivated: () -> Void

// Keep focus in the sidebar's view hierarchy, rather than the TabView's parent.
@FocusState private var isFieldFocused: Bool

// MARK: - View

var body: some View {
HStack(spacing: 8) {
Image(systemName: "magnifyingglass")
.foregroundStyle(.secondary)
.accessibilityHidden(true)
TextField(String(localized: "Search") + "...", text: $text)
.textFieldStyle(.plain)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.submitLabel(.search)
.focused($isFieldFocused)
.accessibilityIdentifier("iPadSidebarSearch")
.onSubmit { isFieldFocused = false }
if !text.isEmpty {
Button {
text = ""
onActivated()
isFieldFocused = 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, text.isEmpty ? 12 : 0)
.frame(maxWidth: .infinity, minHeight: 44)
.background(.quaternary, in: .capsule)
.onChange(of: isFieldFocused) { _, focused in
isFocused = focused
if focused { onActivated() }
}
.onChange(of: isFocused, initial: true) { _, focused in
isFieldFocused = focused
}
.task(id: focusRequested) {
guard focusRequested else { return }
// Reapply focus after the detail change, outside the tap's navigation update.
await Task.yield()
guard !Task.isCancelled, focusRequested else { return }
isFieldFocused = true
isFocused = true
focusRequested = false
}
}
}

#Preview {
@Previewable @State var text = ""
@Previewable @State var isFocused = false
@Previewable @State var focusRequested = false

SidebarSearchField(
text: $text,
isFocused: $isFocused,
focusRequested: $focusRequested,
onActivated: {}
)
.padding()
}
#endif
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
//
// TabBarPlacementObserver.swift
// openclient-llm
//
// Created by Arturo Carretero Calvo on 15/09/2026.
// Copyright © 2026 Arturo Carretero Calvo. All rights reserved.
//

import SwiftUI

#if os(iOS)
struct TabBarPlacementObserver: ViewModifier {
@Environment(\.tabBarPlacement) private var placement

let onChange: (TabBarPlacement) -> Void

func body(content: Content) -> some View {
content
.onChange(of: placement, initial: true) { _, placement in
guard let placement else { return }
onChange(placement)
}
}
}
#endif